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

Soumava DasSoumava Das
10 min read

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:

FeatureApp RouterPages 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.

  1. 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 type WeatherData, or

  • error: 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:

  1. Runtime validation: Even if TypeScript compiles, the API might return unexpected data. Zod ensures it conforms at runtime.

  2. Better error handling: Fail gracefully when the API returns wrong/missing fields.

  3. 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.

0
Subscribe to my newsletter

Read articles from Soumava Das directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Soumava Das
Soumava Das