Aphiwad Chhoeun

Weather app with Next.js and OpenWeahterMap API

Build weather app using OpenWeatherMap API

First, let’s look at the wireframes of what we’re gonna build.

Tools used:

Wireframes

Weather Diagrams

The app consists two pages:

Setup Next.js project

yarn create next-app

Create API ENV vars

You need API token to use Open Weather Map API, and Mapbox static map image API.

Create a new file .env.local and save it at the root directory of the project.

Signed up and goto Open Weather Map API.

Signed up and goto Mapbox API.

// .env.local
NEXT_PUBLIC_OPENWEATHER_API=
NEXT_PUBLIC_MAPBOX_API=

Reusabeld Custom Hooks

React Query is an incredible libary, it comes with:

I created two custom hooks:

useLocation()

// useLocation.js
import { useQuery } from '@tanstack/react-query'

export const fetchLatLng = async (city) => {
  const res = await fetch(
    `https://api.openweathermap.org/geo/1.0/direct?q=${city}&limit=3&appid=${process.env.NEXT_PUBLIC_OPENWEATHER_API}`
  )
  return await res.json()
}

export function useLocation(location, options = {}) {
  let result = useQuery(
    ['GeoLocation', location],
    () => fetchLatLng(location),
    {
      ...options,
      staleTime: 10 * 60 * 1000,
    }
  )

  return result
}

useWeather()

// useWeather.js
import { useQuery } from '@tanstack/react-query'

export const fetchWeather = async (latLng) => {
  if (!latLng) return null
  const res = await fetch(
    `https://api.openweathermap.org/data/2.5/weather?lat=${latLng.lat}&lon=${latLng.lon}&units=imperial&appid=${process.env.NEXT_PUBLIC_OPENWEATHER_API}`
  )
  return await res.json()
}

export function useWeather(latLng, options = {}) {
  let result = useQuery(
    ['Weather', latLng?.lat, latLng?.lon],
    () => fetchWeather(latLng),
    {
      ...options,
      staleTime: 10 * 60 * 1000,
    }
  )

  return result
}

Location Search Component

// LocationSearch.jsx
import { useState } from 'react'
import { useRouter } from 'next/router'

import { ActionIcon, Container, Stack, TextInput } from '@mantine/core'
import { useForm } from '@mantine/form'
import { IconHome2, IconSearch } from '@tabler/icons'

import ReusableLoader from '../ReusableLoader/ReusableLoader'
import LocationResult from './LocationResult'

import { useLocation } from '../../hooks/useLocation'

const LocationSearch = (props) => {
  const router = useRouter()
  const [query, setQuery] = useState('')
  const form = useForm({
    initialValues: {
      locationQuery: '',
    },
  })
  const { isLoading, data } = useLocation(query, {
    enabled: !!query,
  })

  const formHandler = (values) => {
    const { locationQuery } = values
    setQuery(locationQuery)
  }

  const searchResultHandler = (latlon) => {
    router.push('/location/' + [latlon.lat, latlon.lon].join(','))
  }

  return (
    <Container>
      <Stack>
        <form onSubmit={form.onSubmit((values) => formHandler(values))}>
          <TextInput
            label="Search location"
            size={'lg'}
            icon={<IconHome2 />}
            rightSection={
              <ActionIcon
                onClick={form.onSubmit((values) => formHandler(values))}
              >
                <IconSearch />
              </ActionIcon>
            }
            {...form.getInputProps('locationQuery')}
          />
        </form>

        {!!query ? (
          <div>
            {isLoading ? (
              <ReusableLoader />
            ) : (
              <LocationResult
                locations={data}
                locationHandler={searchResultHandler}
              />
            )}
          </div>
        ) : null}
      </Stack>
    </Container>
  )
}

export default LocationSearch
Location search screenshot

Weather Details Component

// /pages/location/[location].jsx
import { useRouter } from 'next/router'
import { useMemo } from 'react'

import { Container, Card, Text, Stack, Group } from '@mantine/core'
import {
  IconSunrise,
  IconSunset,
  IconTemperatureMinus,
  IconTemperaturePlus,
} from '@tabler/icons'

import Layout from '../../components/layout'
import BackLink from '../../components/BackLink/BackLink'
import LocationSkeleton from '../../components/ReusableLoader/LocationSkeleton'
import StaticMap from '../../components/StaticMap/StaticMap'
import TemperatureDisplay from '../../components/TemperatureDisplay/TemperatureDisplay'
import WeatherImage from '../../components/WeatherImage/WeatherImage'
import LocaleTime from '../../components/LocaleTime/LocaleTime'
import WeatherDescription from '../../components/WeatherDescription/WeatherDescription'

import { useLocation } from '../../hooks/useLocation'
import { useWeather } from '../../hooks/useWeather'

const LocationPage = (props) => {
  const {
    query: { location: locationParams },
  } = useRouter()
  const latlon = useMemo(() => {
    if (!locationParams) return null

    let temp = locationParams.split(',')
    return {
      lat: temp[0],
      lon: temp[1],
    }
  }, [locationParams])

  let { isLoading, data: weatherData } = useWeather(latlon, {
    enabled: !!latlon,
  })

  const loadingSkeleton = <LocationSkeleton />

  const backLink = <BackLink />

  return (
    <Layout appBar={backLink}>
      <Container>
        <Card withBorder shadow="sm" p="lg">
          {isLoading ? (
            loadingSkeleton
          ) : (
            <>
              <Group position="apart">
                <TemperatureDisplay size={64} weight={'bold'}>
                  {weatherData.main.temp}
                </TemperatureDisplay>
                <Stack>
                  <Group>
                    <TemperatureDisplay>
                      {weatherData.main.temp_max}
                    </TemperatureDisplay>
                    <IconTemperaturePlus />
                  </Group>
                  <Group>
                    <TemperatureDisplay>
                      {weatherData.main.temp_min}
                    </TemperatureDisplay>
                    <IconTemperatureMinus />
                  </Group>
                </Stack>
              </Group>

              <Stack>
                <Group position="apart">
                  <Text size={36}>{weatherData.name}</Text>
                  <LocaleTime offset={weatherData.timezone} />
                </Group>
                <Group>
                  <IconSunrise />
                  <LocaleTime
                    offset={weatherData.timezone}
                    timestamp={weatherData.sys.sunrise * 1000}
                  />

                  <IconSunset />
                  <LocaleTime
                    offset={weatherData.timezone}
                    timestamp={weatherData.sys.sunset * 1000}
                  />
                </Group>
                <Group position="apart">
                  <div style={{ width: '5rem' }}>
                    <WeatherImage weatherData={weatherData.weather[0]} />
                  </div>
                  <WeatherDescription
                    text={weatherData.weather[0].description}
                  />
                </Group>
                <Group position="apart">
                  <Stack>
                    <Text size={24} color="dimmed">
                      Humidty
                    </Text>
                    <Text size={24} color="light">
                      {weatherData.main.humidity} %
                    </Text>
                  </Stack>
                  <Stack>
                    <Text size={24} color="dimmed">
                      Wind
                    </Text>
                    <Text size={24} color="light">
                      {weatherData.wind.speed} mph
                    </Text>
                  </Stack>
                </Group>
              </Stack>

              <Card.Section mt={'md'} mb={'-lg'}>
                <StaticMap weatherData={weatherData} size={[928, 280]} />
              </Card.Section>
            </>
          )}
        </Card>
      </Container>
    </Layout>
  )
}

export default LocationPage
Weather details

Source code

Demo | Github