Step-by-step process to create a Weather App from Scratch


Introduction
The purpose of this project is to develop a clear understanding of how to fetch data from an API and present it in a visually appealing manner.
Step 1: Creating a new Next.js Project
With node.js
and npm
installed, please run the following command to create a Next.js project
npx create-next-app@latest
After selecting "Yes," you will be presented with a series of questions. Below are the options I chose during the project setup. I will explain why I selected certain options as "Yes" and others as "No"
What is your project named? <projec_name>
Would you like to use TypeScript? Yes
Would you like to use ESLint? Yes
Would you like to use Tailwind CSS? Yes
Would you like your code inside a `src/` directory? No
Would you like to use App Router? (recommended) Yes
Would you like to use Turbopack for `next dev`? Yes
Would you like to customize the import alias (`@/*` by default)? No
I chose TypeScript as my programming language for a few reasons: it provides static typing, which means you can define your variables, it allows for early error detection, and it is scalable for large projects.
ESLint helps in identifying errors quickly. Using Tailwind CSS is optional, but I recommend it as it allows you to apply styles more efficiently compared to vanilla CSS. For this project, I am not using the src/
folder structure, although you may choose to use it if you prefer.
You might be wondering why the App Router is recommended. This is because of the following reasons:
Feature | App Router | Pages Router |
React Server Components | ✅ Yes | ❌ No |
Nested Layouts | ✅ Yes | ❌ No |
Loading/Error UI per route | ✅ Yes | ❌ No |
Streaming | ✅ Yes | ❌ No |
Simplified Data Fetching | ✅ Yes | ❌ No |
Why Turbopack?
I saw most of the YouTube tutorials recommend selecting ‘Yes‘ for Turbopack. Out of curiosity, I referred to the Next.js documentation and found the following information:
Turbopack is an incremental bundler optimized for JavaScript and TypeScript, written in Rust, and built into Next.js. You can use Turbopack with both the Pages and App Router for a much faster local development experience.
In short, Turbopack helps optimize your local development environment, allowing your code to run faster.
Would you like to customize the import alias (`@/*` by default)? No
You might be wondering why I selected "No" instead of "Yes." Initially, I wasn’t sure either, so I searched online and found that choosing "Yes" requires additional configuration of the tsconfig.json
or jsconfig.json
file.
You get clean, readable imports like import Button from "@/components/Button"
instead of long relative paths like ../../../components/Button
After selecting all the options, you will need to wait for around 3 minutes or longer, depending on your internet speed, as it installs all the necessary libraries. Once the installation is complete, run npm run dev
in the terminal to start the development environment.
Your terminal will have some similar output like the above pic.
Before we begin working on the project, we need to clean up the project structure by removing unnecessary folders and files. For example, you can delete the public
folder, which contains all the SVGs, and also remove the favicon.ico
file (this is the icon displayed in the browser tab).
Your app/page.tsx should look like this
export default function Home() {
return (
<div className="text-5xl">
Hello World!
</div>
)
}
After making these changes, you can check your browser and see the "Hello World" text displayed in extra-large letters. This is due to the text-5xl
class from Tailwind CSS. If you are not familiar with Tailwind CSS, I highly recommend exploring their documentation.
After the basic setup, you would need to make the following files
app/type/weather.tsx
export interface WeatherData {
name: string;
main: {
temp: number;
humidity: number;
feels_like: number;
};
weather: Array<{
main: string;
description: string;
icon: string;
}>;
wind: {
speed: number;
};
timezone: number;
dt: number;
}
If you are familiar with the basics of TypeScript, you would know that an interface provides type safety. This means that each variable is expected to have a specific type, which helps ensure that the data is accurate and consistent. As a result, it leads to cleaner and more reliable code.
These types will be frequently used throughout the project.
app/utils/time.tsx
import { WeatherData } from "../types/weather";
export function formatLocalTime(weatherData: WeatherData): string {
const localTimestamp = weatherData.dt + weatherData.timezone;
const date = new Date(localTimestamp * 1000);
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
timeZone: 'UTC'
});
}
export function isDayTime(weatherData: WeatherData): boolean {
const localTimestamp = weatherData.dt + weatherData.timezone;
const date = new Date(localTimestamp * 1000);
const hours = date.getUTCHours();
return hours >= 6 && hours < 18;
}
In this code we have two functions i will explain both functions in detail
1. formatLocalTime function
We are formatting the local time (in HH:MM AM/PM
format) based on weather data fetched from the API.
localTimestamp = dt + timezone
– Adds the offset to the UTC timestamp to get the local timestamp.
new Date(localTimestamp * 1000)
– Converts the timestamp to milliseconds (as required by JavaScript Date
) and creates a Date
object.
toLocaleTimeString(...)
– Formats the time as a string in HH:MM
format (12-hour clock) using 'en-US'
locale.
- isDayTime function
The purpose of this function is to check if it’s daytime
getUTCHours()
– Gets the hour part of the local time (0–23).
The return statement checks if the hour is between 6 and 17. If it is, it is considered daytime; otherwise, it is considered nighttime. In this case, the boolean value returned is true
, indicating it is daytime.
app/actions.tsx
"use server";
import { WeatherData } from "./types/weather";
import { z } from "zod";
const weatherSchema = z.object({
name: z.string(), // City name
main: z.object({
temp: z.number(), // Current temperature
humidity: z.number(), // Humidity percentage
feels_like: z.number(), // "Feels like" temperature
}),
weather: z.array(
z.object({
main: z.string(), // General weather type (e.g., Clouds)
description: z.string(), // Detailed weather description
icon: z.string(), // Icon code
}),
),
wind: z.object({
speed: z.number(), // Wind speed
}),
timezone: z.number(), // Timezone offset in seconds
dt: z.number(), // Timestamp of the data
});
export async function getWeatherData(city: string): Promise<{
data?: WeatherData;
error?: string
}> {
try {
if(!city.trim()) { // checks if the city is empty or only whitespace
return { error: "City name is required" };
}
const res = await fetch(
`https://api.openweathermap.org/data/2.5/weather?q=${city}&units=metric&appid=${process.env.OPENWEATHERMAP_API_KEY}`
);
if (!res.ok) {
throw new Error("City not found")
}
const rawData = await res.json();
const data = weatherSchema.parse(rawData);
return { data };
} catch (error) {
if (error instanceof z.ZodError) {
return { error: "Invalid weather data recived" };
}
return {
error: error instanceof Error ? error.message : "Failed to fetch weather data"
}
}
}
At the top, you can see that I have used "use server"
to indicate that this code should run on the server side. This is typically used with the App Router in server components or route handlers. Now, let’s go into detail about what this code is actually doing.
WeatherSchema - Zod Validation Schema
This part defines a Zod schema that mirrors the structure of the data returned by the OpenWeatherMap API, it validates whether the actual API response conforms to this structure.
getWetherData(city: string) - Main Function
It accepts a city name as input, and returns a promise with either
data
: of typeWeatherData
, orerror
: an error message
When fetching data from the API, you will notice the use of appid
. This is the API key that you receive for free upon registering on the OpenWeather website. After registration, go to the API tab and copy your API key. Then, create an .env
file to store the key securely. Make sure to add this file to your .gitignore
to prevent the API key from being accidentally exposed when pushing your code to GitHub.
OPENWEATHERMAP_API_KEY=<YOUR_API_KEY>
Why use Zod for data validation?
I used Zod because of the following reasons:
Runtime validation: Even if TypeScript compiles, the API might return unexpected data. Zod ensures it conforms at runtime.
Better error handling: Fail gracefully when the API returns wrong/missing fields.
Type safety: After parsing, TypeScript knows exactly what fields are available.
Now coming to the main entry file i.e. page.tsx
in app folder
app/page.tsx
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Droplet, Search, Thermometer, Wind } from "lucide-react";
import { getWeatherData } from "./actions";
import { WeatherData } from "./types/weather";
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { useFormStatus } from "react-dom";
import { motion } from "framer-motion";
import { formatLocalTime, isDayTime } from "./utils/time";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button type="submit" disabled={pending}>
<Search className={`h-4 w-4 ${pending ? "animate-spin" : ""}`} />
</Button>
);
}
export default function Home() {
const [weather, setWeather] = useState<WeatherData | null>(null);
const [error, setError] = useState<string>("");
const [unit, setUnit] = useState<"celsius" | "fahrenheit">("celsius");
const convertTemp = (temp: number): number => {
if (unit === "fahrenheit") {
return (temp * 9) / 5 + 32;
}
return temp;
};
const handleSeach = async (formData: FormData) => {
setError("");
const city = formData.get("city") as string;
const { data, error: weatherApiError } = await getWeatherData(city);
console.log(error);
if (weatherApiError) {
setError(weatherApiError);
}
if (data) {
setWeather(data);
}
};
return (
<div
className={`min-h-screen bg-gradient-to-b from-sky-400 to-blue-500 p-4 flex items-center justify-center transition-colors duration-1000 ${
weather
? isDayTime(weather)
? "bg-gradient-to-b from-sky-400 to-blue-500"
: "bg-gradient-to-b from-blue-900 to-gray-900"
: "bg-gradient-to-b from-sky-400 to-blue-500"
}`}
>
<div className="w-full max-w-md space-y-4">
<form action={handleSeach} className="flex gap-2">
<Input
name="city"
type="text"
placeholder="Enter city name..."
className="bg-white/90"
required
/>
<SubmitButton />
</form>
<Button
variant="outline"
onClick={() => setUnit(unit === "celsius" ? "fahrenheit" : "celsius")}
className="bg-white/90"
>
°{unit === "celsius" ? "C" : "F"}
</Button>
{error && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="text-center text-red-200 bg-red-500/20 rounded-md p-2"
>
{error}
</motion.div>
)}
{weather && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
<Card className={`backdrop-blur ${
isDayTime(weather)
? "bg-white/50"
: "bg-gray-800/50 text-white"
}`}>
<CardContent className="p-6">
<div className="text-center mb-4">
<motion.div
initial={{ scale: 0.5 }}
animate={{ scale: 1 }}
className="flex justify-between items-start"
>
<h2 className="text-2xl font-bold">{weather.name}</h2>
<div className="text-sm bg-black/10 dark:bg-white/10 px-2 py-1 rounded-md">
{formatLocalTime(weather)}
</div>
</motion.div>
<div className="flex items-center justify-center gap-2 mt-2">
<motion.img
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
src={`https://openweathermap.org/img/wn/${weather.weather[0].icon}@2x.png`}
alt={weather.weather[0].description}
width={64}
height={64}
/>
<motion.div
key={`temp-${unit}`}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.2 }}
className="text-5xl font-bold"
>
{Math.round(convertTemp(weather.main.temp))}°{unit === "celsius" ? "C" : "F"}
</motion.div>
</div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="text-gray-500 mt-1 capitalize"
>
{weather.weather[0].description}
</motion.div>
</div>
<div className="grid grid-cols-3 gap-4 mt-6">
<div className="text-center">
<Thermometer className="w-6 h-6 mx-auto text-orange-500" />
<div className="mt-2 text-sm text-gray-500">Feels Like</div>
<div className="font-semibold">
{Math.round(convertTemp(weather.main.feels_like))}°{unit === "celsius" ? "C" : "F"}
</div>
</div>
<div className="text-center">
<Droplet className="w-6 h-6 mx-auto text-blue-500" />
<div className="mt-2 text-sm text-gray-500">Humidity</div>
<div className="font-semibold">
{Math.round(weather.main.humidity)}%
</div>
</div>
<div className="text-center">
<Wind className="w-6 h-6 mx-auto text-teal-500" />
<div className="mt-2 text-sm text-gray-500">Wind</div>
<div className="font-semibold">
{Math.round(weather.wind.speed)} m/s
</div>
</div>
</div>
</CardContent>
</Card>
</motion.div>
)}
</div>
</div>
);
}
For the icons, I used pre-built icons from Lucide React. For the Card and Button components, I used ShadCN. To add the Button, I ran the command npx shadcn@latest add button
, and for the Card, I used npx shadcn@latest add card
. Both commands can be found in the ShadCN UI documentation.
You may also notice that I have used Framer Motion in some of the HTML elements. You can install Framer Motion by running the following command: npm i framer-motion
.
If you have followed all the steps mentioned above, your browser should display the following result.
Congratulations 🎉 You have successfully built your first weather app using Next.js, TypeScript, and Tailwind CSS.
Conclusion
You can view the live project at the following link: Live Project
Source code is available here: GitHub Repository
I welcome your feedback and would be happy to read your comments. I am also open to any constructive criticism that can help me improve.
Thank you.
Subscribe to my newsletter
Read articles from Soumava Das directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
