Building a Weather Search Application with Next.js
Table of contents
- Setting Up the Project
- Project Structure
- 1. First, let’s start with our root layout:
- 2. Now, let’s create our home page:
- 3. Let’s go to the components folder and create our components:
- 4. Now Let’s do State Management with Redux Toolkit:
- 5. let’s create our API utility functions:
- 6. Let’s create a types.ts file for our custom types:
- 7. Finally, Let’s create our weather page:
In this article, We’ll create a weather search application using Next.js and Redux Toolkit. This project is a practical way to learn how to build a dynamic web application with powerful state management. We’ll also use Tailwind CSS for styling, making the UI responsive and visually appealing.
Setting Up the Project
Let’s start by setting up our Next.js project. First, create a new Next.js application using the following command:
npx create-next-app@latest weather-app
After the project is created, navigate to the project directory:
cd weather-app
Next, we’ll install the necessary dependencies:
npm install @reduxjs/toolkit react-redux lucide-react axios
Project Structure
Our project will follow a well-organized structure, making it easy to manage and scale. Here’s how it’s set up:
weather-search-app/
│
├── .next/ # Next.js build output (generated, don't edit)
├── node_modules/ # Node.js dependencies (generated)
├── public/ # Static files
│ └── favicon.ico
│
├── src/
│ ├── app/
│ │ ├── layout.tsx # Root layout component
│ │ ├── page.tsx # Home page component
│ │ ├── globals.css # Global styles (moved to the same level as layout.tsx)
│ │ └──weather/[location]/
│ │ └── page.tsx # Dynamic route for weather details page
│ │
│ ├── components/
│ │ ├── AutocompleteList.tsx
│ │ ├── SearchBox.tsx
│ │ └── ui/ # ui components
│ │ ├── alert.tsx
│ │ ├── card.tsx
│ │
│ │
│ ├── lib/
│ │ └── store/
│ │ ├── Provider.tsx # Redux Provider component
│ │ ├── store.ts # Redux store configuration
│ │ └── weatherSlice.ts # Weather-related Redux slice
│ │
│ ├── utils/ # Utility functions and types
│ │ ├── api.ts # API utility functions
│ │ └── types.ts # TypeScript types
│
├── .env.local # Local environment variables (not in version control)
├── .eslintrc.json # ESLint configuration
├── .gitignore # Git ignore file
├── next.config.js # Next.js configuration
├── package.json # Project dependencies and scripts
├── postcss.config.js # PostCSS configuration (for Tailwind)
├── README.md # Project documentation
├── tailwind.config.js # Tailwind CSS configuration
└── tsconfig.json # TypeScript configuration1. First, let’s start with our root layout:
1. First, let’s start with our root layout:
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { ReduxProvider } from "@/lib/store/provider";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Weather Search",
description: "Search for weather information",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
<ReduxProvider>
<main className="bg-white w-full h-screen">{children}</main>
</ReduxProvider>
</body>
</html>
);
}
2. Now, let’s create our home page:
import React from 'react'
import AutocompleteList from '@/components/AutocompleteList'
import SearchBox from '@/components/SearchBox'
import { Sun, Cloud, CloudRain } from 'lucide-react'
export default function Home() {
return (
<div className="flex flex-col min-h-screen bg-gradient-to-b from-blue-100 to-white">
<main className="flex-grow flex flex-col items-center justify-center px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<h1 className="mt-6 text-5xl font-extrabold text-gray-900">
Weather Search
</h1>
<p className="mt-2 text-sm text-gray-600">
Enter a location to get instant weather information
</p>
</div>
<div className="mt-8 space-y-6">
<SearchBox />
<AutocompleteList />
</div>
<div className="flex justify-center space-x-4 mt-12">
<Sun className="text-yellow-400 w-12 h-12" />
<Cloud className="text-gray-400 w-12 h-12" />
<CloudRain className="text-blue-400 w-12 h-12" />
</div>
</div>
</main>
<footer className="w-full py-4 text-center text-sm text-gray-500 bg-white bg-opacity-50">
<p>© 2024 Weather Search App. All rights reserved.</p>
<p className="mt-1">Powered by WeatherUnion</p>
</footer>
</div>
)
}
3. Let’s go to the components folder and create our components:
i. SearchBox component: The SearchBox
component is responsible for capturing user input. It allows users to type in the name of a location, triggering an autocomplete feature that suggests possible matches.
'use client'
import React, { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useDispatch } from 'react-redux'
import { searchLocalitiesAsync } from '@/lib/store/weatherSlice'
import { AppDispatch } from '@/lib/store/store'
const SearchBox: React.FC = () => {
const [localSearchTerm, setLocalSearchTerm] = useState('')
const router = useRouter()
const dispatch = useDispatch<AppDispatch>()
useEffect(() => {
if (localSearchTerm) {
dispatch(searchLocalitiesAsync(localSearchTerm))
}
}, [localSearchTerm, dispatch])
return (
<form onSubmit={(e) => e.preventDefault()} className="w-full max-w-md">
<div className="relative">
<input
type="text"
value={localSearchTerm}
onChange={(e) => setLocalSearchTerm(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-slate-950"
placeholder="Search for a location..."
/>
<button
type="submit"
className="absolute right-0 top-0 mt-3 mr-4 text-gray-500 hover:text-gray-700"
>
<svg className="h-5 w-5" fill="none" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" stroke="currentColor">
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</button>
</div>
</form>
)
}
export default SearchBox
ii. AutocompleteList component: The AutocompleteList
component displays suggestions as the user types in the SearchBox
. When a user selects a suggestion, the application fetches and displays the weather data for that location.
'use client'
import React from 'react'
import { useSelector } from 'react-redux'
import Link from 'next/link'
import { RootState } from '@/lib/store/store'
const AutocompleteList: React.FC = () => {
const { suggestions } = useSelector((state: RootState) => state.weather)
if (suggestions.length === 0) return null
return (
<ul className="mt-2 w-full max-w-md max-h-[300px] overflow-y-scroll bg-white border border-gray-300 rounded-lg shadow-lg">
{suggestions.map((suggestion) => (
<li key={suggestion.id} className="px-4 py-2 hover:bg-gray-100">
<Link href={`/weather/${encodeURIComponent(suggestion.id)}`}>
{suggestion.name}
</Link>
</li>
))}
</ul>
)
}
export default AutocompleteList
iii. Reuseable card component:
import React, { ReactNode } from "react";
interface CardProps {
children: ReactNode;
className?: string;
}
export const Card = ({ children, className }: CardProps) => {
return (
<div className={`bg-white shadow-md rounded-lg", ${className}`}>
{children}
</div>
);
};
interface CardContentProps {
children: ReactNode;
className?: string;
}
export const CardContent = ({ children, className }: CardContentProps) => {
return <div className={`${className}`}>{children}</div>;
};
interface CardHeaderProps {
children: ReactNode;
className?: string;
}
export const CardHeader = ({ children, className }: CardHeaderProps) => {
return <div className={`border-b", ${className}`}>{children}</div>;
};
interface CardTitleProps {
children: ReactNode;
className?: string;
}
export const CardTitle = ({ children, className }: CardTitleProps) => {
return (
<h2 className={`text-lg font-semibold", ${className}`}>{children}</h2>
);
};
iv. Alert component:
import React, { ReactNode } from "react";
interface AlertProps {
children: ReactNode;
variant?: "default" | "destructive";
}
export const Alert = ({ children, variant = "default" }: AlertProps) => {
return (
<div
className={`p-4 rounded-lg flex items-start space-x-3",
${variant === "destructive" ? "bg-red-100 text-red-700" : "bg-gray-100 text-gray-700"}
`}
>
{children}
</div>
);
};
interface AlertTitleProps {
children: ReactNode;
className?: string;
}
export const AlertTitle = ({ children, className }: AlertTitleProps) => {
return (
<h3 className={`text-lg font-semibold", ${className}`}>{children}</h3>
);
};
interface AlertDescriptionProps {
children: ReactNode;
className?: string;
}
export const AlertDescription = ({
children,
className,
}: AlertDescriptionProps) => {
return (
<div className={`text-sm", ${className}`}>{children}</div>
);
};
4. Now Let’s do State Management with Redux Toolkit:
To manage the state of our application, we’ll use the Redux Toolkit. We start by creating a slice src/lib/store
to manage weather-related data. The Redux store manages the global state of our application, including search terms, suggestions, and weather data.
i. Redux store:
import { configureStore } from '@reduxjs/toolkit'
import weatherReducer from './weatherSlice'
export const store = configureStore({
reducer: {
weather: weatherReducer,
},
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
ii. Redux provider:
'use client'
import { Provider } from 'react-redux'
import { store } from './store'
export function ReduxProvider({ children }: { children: React.ReactNode }) {
return <Provider store={store}>{children}</Provider>
}
iii. Weather Slice:
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { fetchWeatherByCoordinates, fetchWeatherByLocality, searchLocalities } from '@/utils/api';
import { WeatherData } from '@/utils/types';
interface WeatherState {
searchTerm: string;
suggestions: Array<{ id: string; name: string }>;
weatherData: WeatherData | null;
loading: boolean;
error: string | null;
}
const initialState: WeatherState = {
searchTerm: '',
suggestions: [],
weatherData: null,
loading: false,
error: null,
};
export const fetchWeatherByCoordinatesAsync = createAsyncThunk(
'weather/fetchByCoordinates',
async ({ latitude, longitude }: { latitude: number; longitude: number }) => {
const response = await fetchWeatherByCoordinates(latitude, longitude);
return response as WeatherData;
}
);
export const fetchWeatherByLocalityAsync = createAsyncThunk(
'weather/fetchByLocality',
async (localityId: string) => {
const response = await fetchWeatherByLocality(localityId);
return response as WeatherData;
}
);
export const searchLocalitiesAsync = createAsyncThunk(
'weather/searchLocalities',
async (query: string) => {
const response = await searchLocalities(query);
return response;
}
);
const weatherSlice = createSlice({
name: 'weather',
initialState,
reducers: {
setSearchTerm(state, action: PayloadAction<string>) {
state.searchTerm = action.payload;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchWeatherByCoordinatesAsync.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchWeatherByCoordinatesAsync.fulfilled, (state, action) => {
state.weatherData = action.payload;
state.loading = false;
})
.addCase(fetchWeatherByCoordinatesAsync.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message || 'Failed to fetch weather data';
})
.addCase(fetchWeatherByLocalityAsync.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchWeatherByLocalityAsync.fulfilled, (state, action) => {
state.weatherData = action.payload;
state.loading = false;
})
.addCase(fetchWeatherByLocalityAsync.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message || 'Failed to fetch weather data';
})
.addCase(searchLocalitiesAsync.fulfilled, (state, action) => {
state.suggestions = action.payload;
});
},
});
export const { setSearchTerm } = weatherSlice.actions;
export default weatherSlice.reducer;
5. let’s create our API utility functions:
We’ve created utility functions api.ts
for interacting with the Weather Union API.
import axios from "axios";
const API_KEY = process.env.NEXT_PUBLIC_WEATHER_API_KEY;
const BASE_URL = "https://www.weatherunion.com/gw/weather/external/v0";
export const fetchWeatherByCoordinates = async (
latitude: number,
longitude: number
) => {
const options = {
method: "GET",
url: `${BASE_URL}/get_weather_data`,
params: { latitude, longitude },
headers: { "X-Zomato-Api-Key": API_KEY },
};
try {
const { data } = await axios.request(options);
return data;
} catch (error) {
console.error("Error fetching weather data:", error);
throw error;
}
};
export const fetchWeatherByLocality = async (localityId: string) => {
const options = {
method: "GET",
url: `${BASE_URL}/get_locality_weather_data`,
params: { locality_id: localityId },
headers: { "X-Zomato-Api-Key": API_KEY },
};
try {
const { data } = await axios.request(options);
return data;
} catch (error) {
console.error("Error fetching weather data:", error);
throw error;
}
};
export const searchLocalities = async (query: string) => {
return [
{ id: "ZWL001156", name: "New York", country: "United States" },
{ id: "ZWL008975", name: "Mumbai", country: "India" },
{ id: "ZWL007486", name: "Delhi", country: "India" },
{ id: "ZWL005087", name: "Gurgaon", country: "India" },
{ id: "ZWL005375", name: "Bangalore", country: "India" },
{ id: "ZWL007934", name: "Kolkata", country: "India" },
{ id: "ZWL006743", name: "Pune", country: "India" },
{ id: "ZWL003283", name: "Hyderabad", country: "India" },
{ id: "ZWL007059", name: "Chennai", country: "India" },
{ id: "ZWL003455", name: "Ahmedabad", country: "India" },
{ id: "ZWL006687", name: "Chandigarh", country: "India" },
{ id: "ZWL002150", name: "Goa", country: "India" },
{ id: "ZWL006900", name: "Bhopal", country: "India" },
{ id: "ZWL002155", name: "Surat", country: "India" },
{ id: "ZWL008753", name: "Jammu", country: "India" },
{ id: "ZWL008938", name: "Vadodara", country: "India" },
{ id: "ZWL009668", name: "Coimbatore", country: "India" },
{ id: "ZWL008906", name: "Bhubaneswar", country: "India" },
].filter((locality) =>
locality.name.toLowerCase().includes(query.toLowerCase())
);
};
Remember to create a .env.local
file in your project root and add your Weather Union API key, visit https://www.weatherunion.com to get the key:
NEXT_PUBLIC_WEATHER_API_KEY=your_api_key_here
6. Let’s create a types.ts
file for our custom types:
export interface WeatherData {
locality_weather_data: {
temperature: number,
humidity: number,
wind_speed: number,
wind_direction: number,
rain_intensity: number,
rain_accumulation: number
}
}
7. Finally, Let’s create our weather page:
The weather page ([location]/page.tsx
) fetches and displays weather data for the selected location.
"use client";
import React, { useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import { RootState, AppDispatch } from "@/lib/store/store";
import { fetchWeatherByLocalityAsync } from "@/lib/store/weatherSlice";
import Link from "next/link";
import { Cloud, Droplets, Wind, Umbrella } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
const WeatherPage = ({ params }: { params: { location: string } }) => {
const dispatch = useDispatch<AppDispatch>();
const { weatherData, loading, error, suggestions } = useSelector(
(state: RootState) => state.weather
);
useEffect(() => {
if (params.location && typeof params.location === "string") {
dispatch(fetchWeatherByLocalityAsync(params.location));
}
}, [params.location, dispatch]);
const name = suggestions.find((suggestion) => suggestion.id === params.location)?.name;
if (!weatherData) return null;
const getWeatherIcon = (temperature: number) => {
if (temperature > 30) return "☀️";
if (temperature > 20) return "🌤️";
if (temperature > 10) return "⛅";
return "☁️";
};
const getWindDirection = (degrees: number) => {
const directions = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
return directions[Math.round(degrees / 45) % 8];
};
return (
<div className="container mx-auto px-4 py-8 max-w-4xl ">
<h1 className="text-4xl font-bold mb-6 text-center text-gray-900">{name}'s Weather</h1>
{error ? (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : loading ? (
<div className="text-center mt-8">
<div className="animate-spin rounded-full h-20 w-20 border-b-2 border-gray-900 mx-auto"></div>
<p className="mt-4 text-lg sm:text-xl text-gray-900">Loading weather data...</p>
</div>
) : (
<div className="space-y-6">
<Card className="bg-gradient-to-br from-blue-500 to-purple-600 text-white p-6 rounded-md">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-4xl sm:text-6xl font-bold">
{weatherData?.locality_weather_data.temperature}°C
</p>
<p className="text-lg sm:text-xl mt-2">Feels comfortable</p>
</div>
<div className="text-7xl md:text-8xl">
{getWeatherIcon(weatherData?.locality_weather_data.temperature)}
</div>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card className="p-4">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Humidity</CardTitle>
<Droplets className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-xl sm:text-2xl font-bold">{weatherData?.locality_weather_data.humidity}%</div>
</CardContent>
</Card>
<Card className="p-4">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Wind</CardTitle>
<Wind className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-xl sm:text-2xl font-bold">{weatherData?.locality_weather_data.wind_speed} km/h</div>
<p className="text-xs text-muted-foreground">
Direction: {getWindDirection(weatherData?.locality_weather_data.wind_direction)}
</p>
</CardContent>
</Card>
<Card className="p-4">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Rain Intensity</CardTitle>
<Cloud className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-xl sm:text-2xl font-bold">{weatherData?.locality_weather_data.rain_intensity} mm/h</div>
</CardContent>
</Card>
<Card className="p-4">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Rain Accumulation</CardTitle>
<Umbrella className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-xl sm:text-2xl font-bold">{weatherData?.locality_weather_data.rain_accumulation} mm</div>
</CardContent>
</Card>
</div>
<Link
href="/"
className="inline-block w-full text-center py-3 bg-slate-950 text-white rounded-lg hover:bg-gray-900 transition duration-300"
>
Back to Search
</Link>
</div>
)}
</div>
);
};
export default WeatherPage;
If you are curious about globals.css and tailwind.config.js, then
//globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
body {
color: black;
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}
//tailwind.config.js
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
"gradient-conic":
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
},
},
},
plugins: [],
};
export default config;
Following this article will teach you how to build a weather search application with Next.js, Redux Toolkit, and Tailwind CSS. This project not only demonstrates how to manage state and fetch data but also how to structure a scalable web application. Feel free to experiment with the code and extend the functionality to suit your needs.
Thank you for reading this article. if you like this project, then visit GitHub and give it a star: GitHub Link*: [github.com/PrtHub/weather-app*](https://git..
Want to connect with me:
- Twitter: [https://x.com/PritamGhosh010]
- LinkedIn: [https://www.linkedin.com/in/pritam-ghosh-dev]
- GitHub: [https://github.com/PrtHub]
Don’t hesitate to reach out if you have any questions or want to discuss web development further. I’m always happy to connect with fellow developers and tech enthusiasts!