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:
- Next.js (The React Framework for Production)
- Open Weather Map API (Free Tier)
- Mantine.dev (React Component Library)
- Mapbox (Free Tier)
- React Query (Data Fetching and Caching React Library)
Wireframes
The app consists two pages:
- Home: user can search for location based off city for example. eg. San Francisoc
- Details: display details informations of weather including status map of specified city.
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:
- query caching for you based on query keys (you can literally use it as a state managment if you wish to 🔥)
- refetch interval built-in for polling data periodically.
- parallel fetching maximizing concurrency fetching.
- fetch status hooks.
- …etc
I created two custom hooks:
useLocation()
: fetch latitude and longitude based on city name.useWeather()
: fetch weather informations based on latitude and longitude.
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
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