Build a Weather App using Next.js, TypeScript, BCMS, and Tailwind CSS

Build a Weather App .jpeg
By Juliet Ofoegbu
Read time 7 min
Posted on 25 Mar 2026

Are you looking to build a fun and practical web project while sharpening your front-end and back-end integration skills?

Well, you’re in the right place!

Traditionally, CMS platforms were just for blogs or content-heavy websites.

But with BCMS, use a headless CMS not just to manage content but to store and structure dynamic app data like search history. This approach offers several benefits:

  • It is scalable as you can manage large datasets without hardcoding them into your frontend.

  • There is a separation of concerns as your data layer lives independently of your app logic.

BCMS (and headless CMS in general) is a smart choice when you want to build frontend-heavy applications that need reusable and structured data.

In this guide, I’ll walk step-by-step through how to build a weather application, using:

I hope to build something similar to the image below.

Weather app example

By the end of this tutorial, you’ll have a fully functioning weather app that fetches real-time weather data, displays forecasts, and remembers your recent searches (if you’re logged in).

What you'll learn? To build a weather app from scratch

  • How to set up a Next.js app with TypeScript and Tailwind CSS

  • How to fetch data from an external weather API

  • How to send data to BCMS via API on each search

  • How to store and retrieve data using BCMS (headless CMS) and fetch past locations from BCMS on page load

  • How to manage user search history in CMS

  • How to enforce API usage limits to prevent abuse of endpoints.

Getting Started: Step-by-step guide

1. Set up the Next.js project

First, let’s create a new Next.js project:

npx create-next-app@latest weather-scope
cd weather-scope

In the create-next-app prompts, select to use TypeScript and Tailwind CSS.

Install and initialize Tailwind CSS:

npm install tailwindcss@3.4.1 --save -D postcss@8 tailwind-merge

Install other components, like the:

  • date-fns library for date formatting

  • axios library to make HTTTP requests from the web browser

  • upstash/redis library is a serverless Redis database storage for storing rate limit counters persistently (unlike in-memory solutions that reset on server restart)

  • upstash/ratelimit library for enforcing API usage limits and preventing abuse of your weather API endpoint

  • shadcn-ui library for UI components

  • Use-debounce library to delay the execution of functions. In this app, it’ll be useful because we’re dealing with rapid user input during the searches

npm install date-fns axios @upstash/redis @upstash/ratelimit use-debounce shadcn-ui@latest init
npx shadcn@latest add badge button card input skeleton

When you run the shadcn@latest add … command, you’ll be prompted to create a components.json file. Proceed by entering “y” in your terminal. Select a base color (preferably neutral). This command will create a ui folder in the components folder.

Run your app:

npm run dev

Now you’ve got Next.js + Tailwind CSS + TypeScript ready to go.

2. Set up Project Folders and Files

  • Inside the app directory, create a folder called (auth). Create a folder inside it called sign-up. Create a folder inside this folder called [[...sign-up]] and finally, create a page.tsx file inside it.

  • Just like you did for the sign-up, inside the auth folder, create a folder inside it named sign-in. Create another folder inside this folder and name it [[...sign-in]] and finally, create a page.tsx file inside it.

  • Inside the app directory, create a page.tsx file and a folder called (root). Create a folder inside it called forecast/[id] and a 2 pages inside this folder called page.tsx and loading.tsx.

  • Now for the APIs we’ll be making use of in this project, create an api folder inside the app folder. Inside this api folder, create a file called route.ts and five other folders namely: create-history, delete-history, get-history, location, and weather. Finally, create a route.ts file in each of these folders.

  • For the components, refer to the components page in the GitHub repo to view the whole component files to be created.

    NB: The ui folder will automatically be created when you install the scahdcn components.

  • Next is the lib folder. Inside the auto-generated lib folder, a utils.ts will be created, along with a rate-limiter file will be created by default when you install the Upstash rate limiter library. 

  • And finally, create the types folder, where you’ll create two files namely: index.ts and weather.ts.

Project structure:

Here’s the full project structure for this project:

weather-scope/.next/
┣ app/ (auth)/ (root)/
┃ ┣ api/
┃ ┣ favicon.ico
┃ ┣ globals.css
┃ ┣ layout.tsx
┃ ┗ not-found.tsx
┣ bcms/
┃ ┗ types/
┣ components/
┃ ┣ ui/
┃ ┣ ForecastCard.tsx
┃ ┣ Hero.tsx
┃ ┣ Navbar.tsx
┃ ┣ RecentSearches.tsx
┃ ┣ RefreshButton.tsx
┃ ┣ Search.tsx
┃ ┣ WeatherDisplay.tsx
┃ ┣ WeatherHeader.tsx
┃ ┣ WeatherIcon.tsx
┃ ┣ WeatherMainCard.tsx
┃ ┗ WeatherSkeleton.tsx
┣ lib/
┃ ┣ bcms.ts
┃ ┣ rate-limiter.ts
┃ ┗ utils.ts
┣ node_modules/public/
┣ types/
┃ ┣ index.ts
┃ ┗ weather.ts
┣ .env
┣ .gitignore
┣ README.md
┣ bcms.config.cjs
┣ components.json
┣ eslint.config.mjs
┣ middleware.ts
┣ next-env.d.ts
┣ next.config.ts
┣ package-lock.json
┣ package.json
┣ postcss.config.mjs
┣ test.tsx
┗ tsconfig.json

3. Set up the OpenWeather API and Upstash for rate limiting

Setting up an OpenWeather account

Create an account on OpenWeather, and grab your API key.

Create an environment variable file (.env) in the root directory of your project add this line of code:

OPENWEATHER_API_KEY=your_api_key_here

The current weather doc contains details on how to use the current weather data from OpenWeather.

Setting up Upstash account

Create an account on Upstash. Create a database/project and after this has been done, your keys will be available in your dashboard like this:

Upstash account

I've previously installed the upstash redis library, so just go ahead and add the keys to your .env file:

UPSTASH_REDIS_REST_URL=your_redis_rest_url
UPSTASH_REDIS_REST_TOKEN=your_redis_rest_token

# For redis-cli (optional)
UPSTASH_REDIS_PASSWORD=your_upstash_redis_password

4. Add Authentication with Clerk

I want users who are authenticated to have access to only their own search history.

Meanwhile, users who don’t sign up or log in to the app will not have access to any search history as their user ID won’t be stored.

Follow these steps:

Sign up on Clerk

Sign up or log in to your Clerk account and then set up your app in your Clerk dashboard.

Clerk account

Install Clerk into your project

Follow the installation guide and install the Clerk SDK into your project using the following command:

npm install @clerk/nextjs

Get your Clerk keys from your dashboard

Add your Clerk Publishable Key and Secret Key to the  (.env) file, with the Next.js base URL:

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your_publishable_key CLERK_SECRET_KEY=your_secret_key
NEXT_PUBLIC_BASE_URL=http://localhost:3000

Protect Routes

Create a middleware.ts file at the root directory of your project. This file defines a middleware that runs before a request is handled and is used here to protect certain routes with Clerk authentication.

Paste the following lines of code inside the file:

import { clerkMiddleware } from '@clerk/nextjs/server';
export default clerkMiddleware();

export const config = {
 matcher: [
   // Skip Next.js internals and all static files, unless found in search params
   '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
   // Always run for API routes
   '/(api|trpc)(.*)',
 ],
};

Code explanation:

  • The imports clerkMiddleware wraps your middleware to integrate Clerk’s auth and createRouteMatcher helps define which routes should be protected.

  • The middleware config defines which paths the middleware should run on. It skips internal files like /static, /favicon.ico, .css, .js, etc but runs for API routes (like /api/weather, etc.)

Create the auth pages

Input the following lines of code inside the app/(auth)/sign-up/[[...sign-up]]/page.tsx file:

// app/(auth)/sign-up/[[...sign-up]]/page.tsximport { SignUp } from '@clerk/nextjs'const SignUpPage = () => {  return (    <main className="flex h-screen w-full flex-col items-center justify-center gap-10">      <SignUp />    </main>  )}export default SignUpPage

Input the following lines of code inside the app/(auth)/sign-in/[[...sign-in]]/page.tsx file you previously created:

// app/(auth)/sign-in/[[...sign-in]]/page.tsximport { SignIn } from '@clerk/nextjs'const SignInPage = () => {  return (    <main className="flex h-screen w-full flex-col items-center justify-center gap-10">      <SignIn />    </main>  )}export default SignInPage

Code explanation:

In the authentication folders (sign-in and sign-up):

  • You’re rendering Clerk’s prebuilt UI components like <SignIn /> and <SignUp />.

  • They auto-handle redirection after successful sign-in or sign-up.

Wrap your app with ClerkProvider. 

Replace the code in your app/layout.tsx file with this:

// app/layout.tsximport { Inter as FontSans } from "next/font/google"import { cn } from "@/lib/utils"import './globals.css'import { Metadata } from "next"import { ClerkProvider } from "@clerk/nextjs"const fontSans = FontSans({  subsets: ["latin"],  variable: "--font-sans",})export const metadata: Metadata = {  title: 'WeatherScope - Your Personal Weather Tracker',  description: 'Track real-time weather conditions and forecasts with ease. Save your search history and view weather snapshots over time.',}export default function RootLayout({ children }: { children: React.ReactNode }) {  return (    <ClerkProvider      appearance={{        variables: {           colorPrimary: "#3371FF" ,          fontSize: '16px'        },      }}    >      <html lang="en" suppressHydrationWarning>        <body          className={cn(            "min-h-screen font-sans antialiased",            fontSans.variable          )}        >          {children}        </body>      </html>    </ClerkProvider>  )}

Code explanation:

This global layout that wraps the entire app, including pages and components.

  • It wraps the app in <ClerkProvider>, which makes auth context (like user data) available everywhere and appearance lets you customize Clerk’s UI (like button color or font size).

  • The HTML tag sets language and avoids hydration issues (suppressHydrationWarning is useful when using dynamic theming or fonts).

  • Create a not-found.tsx page in the app folder that will show up when a user navigates to an incorrect or invalid URL.

Paste this in it:

// app/not-found.tsx
import Link from'next/link'exportdefaultfunctionNotFound() {  return (    <div>      <h2>Not Found</h2>      <p>Could not find requested resource</p>      <Link href="/">Return Home</Link>    </div>  )}

That’s all for the authentication. Let’s set up the BCMS to act as the backend for storing and fetching weather search history.

5: Set Up BCMS

1. Create an account

Go to https://thebcms.com/ and create an account or log in to your account if you already have one. Once you're in:

  • Create a new project instance: Weather Application.

  • Add a new template called SearchHistory.

2. Define your template

Inside the SearchHistory template, define the properties as follows:

  • Title (string)

  • Slug (string)

  • locationName (string)

  • lat (number)

  • lon (number)

  • temperature (number)

  • timestamp (string)

  • userId (string)

SearchHistory template

3. Get API credentials

Go to Settings > API Keys in your BCMS dashboard and generate a new key by inputting a name and description. The keys you’ll need for the project are:

  • Organization ID

  • Instance ID

  • API Key ID

  • API Key Secret

4. Grant access permissions

Grant access permission for your generated API keys to allow your frontend to fetch data from the BCMS backend. Tick all the checkboxes for the WeatherHistory template, as shown in the image below.

Grant access permissions

Save the changes by hitting the Update key button.

5. Integrate BCMS (for saving search history)

First, install the BCMS client SDK:

npm i --save @thebcms/cli @thebcms/client @thebcms/components-react

After installing, you might need to stop and start up your development server again.

This will create a bcms folder in your root directory and a bcms.ts file in your lib folder.

Next, update your package.json file by modifying the scripts section to this:

"scripts": {  "dev": "bcms --pull types --lng ts && next dev",  "build": "bcms --pull types --lng ts && next build",  "start": "bcms --pull types --lng ts && next start",  "lint": "bcms --pull types --lng ts && next lint"}

In your tsconfig.json file, set moduleResolution to Node:

"moduleResolution":"Node"

Then create a bcms.config.cjs file in the root of your project and paste these lines of code:

/** * @type {import('@thebcms/cli/config').BCMSConfig} */module.exports = {    client: {        orgId: process.env.BCMS_ORG_ID || 'your_bcms_org_id',        instanceId: process.env.BCMS_INSTANCE_ID || 'your_bcms_instance_id',        apiKey: {            id: process.env.BCMS_API_KEY_ID || 'your_bcms_api_key_id',            secret: process.env.BCMS_API_KEY_SECRET || 'your_bcms_api_key_secret',        },    },};

To initialize the BCMS client, create another file inside the generated lib folder, name it bcms.ts, and paste these lines of code in it:

// lib/bcms.tsimport { Client } from '@thebcms/client';export const bcms = new Client(    process.env.BCMS_ORG_ID || '',    process.env.BCMS_INSTANCE_ID || '',    {        id: process.env.BCMS_API_KEY_ID || '',        secret: process.env.BCMS_API_KEY_SECRET || '',    },    {        injectSvg: true,    },);

Next, is to set the next.config.ts file configures your Next.js app to allow images to be loaded from Clerk and BCMS.

Replace your next.config.ts file code with this:

import type { NextConfig } from "next";const nextConfig: NextConfig = {  typescript: {    ignoreBuildErrors: true,  },  images: {    remotePatterns: [{ protocol: 'https', hostname: 'img.clerk.com' }, {protocol: 'https', hostname: 'app.thebcms.com'}]  }};export default nextConfig;

This next.config.ts file also configures the Next.js app to ignore TypeScript build errors so the app can still build even if there are type issues (mainly useful during development).

6. Create the Weather History APIs

This API layer we’ll be creating will interface with OpenWeatherMap to get real-time and forecast weather data, use BCMS as a headless CMS to store and manage search history, protect the endpoints with rate limiting, and support user-based history using Clerk authentication.

Base API Info Route

This is a root-level API welcome/info route. It returns a JSON object describing the app and available endpoints.

Paste these lines of code in your app/api/route.ts file:

import { NextResponse } from "next/server";// Define a custom errorclass ApiError extends Error {  status: number;  constructor(message: string, status: number = 500) {    super(message);    this.name = "ApiError";    this.status = status;  }}export async function GET() {  try {    // Example usage: throw new ApiError("Something went wrong", 400);    return NextResponse.json(      {        message: "Welcome to WeatherScope API",        description: "This is the main API endpoint for WeatherScope - a weather application built with Next.js & BCMS.",        endpoints: {          weather: "/api/weather?lat={latitude}&lon={longitude}",          documentation: "Coming soon"        }      },      {        status: 200      }    );  } catch (error) {    if (error instanceof ApiError) {      return NextResponse.json(        { error: error.message },        { status: error.status }      );    }    return NextResponse.json(      { error: "Internal Server Error" },      { status: 500 }    );  }}

API to Save Search to BCMS

This API basically stores a search history entry in BCMS whenever a user searches for a location.

Paste these lines of code in your app/api/create-history/route.ts file:

import { NextResponse } from "next/server";import { bcms } from "@/lib/bcms";import { auth } from '@clerk/nextjs/server';export async function POST(request: Request) {    const { userId } = await auth(); // <-- get userId from Clerk    try {        // Parse the request body        const body = await request.json();        // Validate required fields (remove userId from required fields)        if (!body.locationName || typeof body.lat !== 'number' || typeof body.lon !== 'number' || typeof body.temperature !== 'number') {            return NextResponse.json(                { success: false, error: "Missing required fields (locationName, lat, lon, temperature)" },                { status: 400 }            );        }        // Generate a slug from location name        const slug = body.locationName.toLowerCase().replace(/\s+/g, '-');                // Create the search history entry        const newHistory = await bcms.entry.create("searchhistory", {            content: [],            statuses: [],            meta: [                {                    lng: "en",                    data: {                        title: body.locationName,                        slug: slug,                        locationname: body.locationName,                        lat: body.lat,                        lon: body.lon,                        temperature: body.temperature,                        timestamp: new Date().toISOString(),                        userid: userId || "guest" // <-- use userId from Clerk                    },                },            ],        });        console.log("userId:", userId);        return NextResponse.json({             success: true,             data: newHistory         }, { status: 201 });    } catch (error) {        console.error("Error creating search history:", error);        return NextResponse.json(            {                 success: false,                 error: "Failed to create search history",                details: error instanceof Error ? error.message : String(error)            },            { status: 500 }        );    }}

Code explanation:

  • Uses bcms.entry.create() to create an entry in the SearchHistory collection.

  • Includes fields like locationName, lat, lon, temperature, timestamp, and optional userId from Clerk.

  • Uses POST request with JSON payload.

  • Automatically generates a slug from the location name.

  • Provides fallback "guest" user if unauthenticated.

API to Get Search History from BCMS

This API fetches and returns all search history entries from BCMS. Paste these lines of code in your app/api/get-history/route.ts file:

// app/api/get-history/route.ts import { NextResponse } from "next/server"; import { bcms } from "@/lib/bcms"; import { SearchhistoryEntry } from "@/bcms/types/ts"; export async function GET() {   try {     // Fetch all search history entries from BCMS     const searchHistory = (await bcms.entry.getAll("searchhistory")) as SearchhistoryEntry[];         // Transform the data to match what the component needs     const transformedData = searchHistory.map(entry => ({       id: entry._id,       name: entry?.meta?.en?.locationname,       lat: entry?.meta?.en?.lat,       lon: entry?.meta?.en?.lon,       temp: `${entry?.meta?.en?.temperature}°C`,       timestamp: entry?.meta?.en?.timestamp // Keep the raw timestamp     }));     // Return the transformed data     return NextResponse.json(transformedData, { status: 200 });   } catch (error) {     console.error("Error fetching search history:", error);     return NextResponse.json(       { success: false, error: "Failed to fetch search history" },       { status: 500 }     );   } }

Code explanation:

  • Uses bcms.entry.getAll("searchhistory") to get all the search history entries being stored in the BCMS

  • Maps the raw BCMS entry data into simplified objects expected by the UI and includes transformed fields like id, locationName, lat, lon, temperature, and timestamp.

  • It then returns the transformed data or an error message.

API to delete from BCMS

This deletes a specific search history entry from the BCMS. Paste these lines of code in your app/api/delete-history/route.ts file:

// app/api/delete-history/route.ts import { NextResponse } from "next/server"; import { bcms } from "@/lib/bcms"; export async function DELETE(request: Request) {   try {     const { entryId } = await request.json();     if (!entryId) {       return NextResponse.json(         { success: false, error: "Missing entryId parameter" },         { status: 400 }       );     }     // Delete the entry from BCMS     await bcms.entry.deleteById(entryId, "searchhistory");     return NextResponse.json(       { success: true, message: "Search history deleted successfully" },       { status: 200 }     );   } catch (error) {     console.error("Error deleting search history:", error);     return NextResponse.json(       {         success: false,         error: "Failed to delete search history",         details: error instanceof Error ? error.message : String(error)       },       { status: 500 }     );   } }

Code explanation:

  • Accepts a DELETE request with JSON body { entryId: "some-id" }.

  • Calls bcms.entry.deleteById() to remove the particular entry.

  • Returns success or error JSON response.

API for Geolocation Autocomplete

This is used for autocomplete suggestions when the user types in a location. Paste these lines of code in your app/api/location/route.ts file:

// app/api/location/route.ts import { NextResponse } from 'next/server'; import axios from 'axios'; import { rateLimiter } from '@/lib/rate-limiter'; interface OpenWeatherLocation {   name: string;   lat: number;   lon: number;   country: string;   state?: string; } interface SimplifiedLocation {   name: string;   lat: number;   lon: number;   country: string;   state?: string;   displayName: string; } export async function GET(request: Request) {   try {     // Rate limiting check     const ip = request.headers.get('x-forwarded-for')?.split(',')[0] || '127.0.0.1';     const { success, limit, remaining, reset } = await rateLimiter.limit(ip);         if (!success) {       return NextResponse.json(         {           error: 'Rate limit exceeded',           limit,           remaining,           reset: new Date(reset).toISOString()         },         {           status: 429,           headers: {             'Retry-After': '10',             'X-RateLimit-Limit': limit.toString(),             'X-RateLimit-Remaining': remaining.toString(),             'X-RateLimit-Reset': reset.toString()           }         }       );     }     const { searchParams } = new URL(request.url);     const query = searchParams.get('q');     const limitParam = searchParams.get('limit') || '5';     const apiKey = process.env.OPENWEATHER_API_KEY;     if (!query) {       return NextResponse.json(         { error: 'Location query is required' },         { status: 400 }       );     }     if (!apiKey) {       return NextResponse.json(         { error: 'OpenWeather API key not configured' },         { status: 500 }       );     }     const url = `http://api.openweathermap.org/geo/1.0/direct?q=${query}&limit=${limitParam}&appid=${apiKey}`;     const response = await axios.get(url);     const locations: SimplifiedLocation[] = (response.data as OpenWeatherLocation[]).map((loc) => ({       name: loc.name,       lat: loc.lat,       lon: loc.lon,       country: loc.country,       state: loc.state,       displayName: `${loc.name}${loc.state ? `, ${loc.state}` : ''}, ${loc.country}`     }));     return NextResponse.json(locations, {       headers: {         'Content-Type': 'application/json',         'Cache-Control': 'public, s-maxage=86400', // Cache for 24 hours         'X-RateLimit-Limit': limit.toString(),         'X-RateLimit-Remaining': remaining.toString(),         'X-RateLimit-Reset': reset.toString()       }     });   } catch (error: unknown) {     let errorMessage = 'Unknown error';     let errorDetails = {};     let status = 500;     if (axios.isAxiosError(error)) {       errorMessage = error.message;       errorDetails = error.response?.data || error.message;       status = error.response?.status || 500;       console.error('Error details:', errorDetails);     } else if (error instanceof Error) {       errorMessage = error.message;       console.error('Error message:', errorMessage);       errorDetails = error.message;       console.error('Error details:', error.message);     } else {       console.error('Error details:', error);     }     return NextResponse.json(       {         error: 'Failed to fetch weather data',         details: errorDetails       },       { status }     );   } }

Code explanation:

  • It calls OpenWeatherMap's Geocoding API to find matching cities.

  • Supports a limit parameter (defaults to 5 suggestions).

  • Returns an array of simplified, user-friendly location objects.

  • Applies rate limiting and basic error handling.

  • Results are cacheable for 24 hours using headers.

API to fetch current + Weather forecast

Its purpose is to fetch both current weather and 5-day forecast based on latitude and longitude. Paste these lines of code in your app/api/weather/route.ts file:

// app/api/weather/route.tsimport { NextResponse } from "next/server";import axios from 'axios';import { rateLimiter } from '@/lib/rate-limiter';import type {   CurrentWeatherResponse,   ForecastResponse, } from '@/types';import { processForecastData } from "@/lib/utils";export async function GET(request: Request) {  try {    // Rate limiting check    const ip = request.headers.get('x-forwarded-for')?.split(',')[0] || '127.0.0.1';    const { success, limit, remaining, reset } = await rateLimiter.limit(ip);        if (!success) {      return NextResponse.json(        {           error: 'Rate limit exceeded',          limit,          remaining,          reset: new Date(reset).toISOString()         },        { status: 429, headers: { 'Retry-After': '10' } }      );    }    const { searchParams } = new URL(request.url);    const lat = searchParams.get('lat');    const lon = searchParams.get('lon');    const apiKey = process.env.OPENWEATHER_API_KEY as string;    if (!apiKey) {      return NextResponse.json(        { error: 'OpenWeather API key not configured' },        { status: 500 }      );    }    if (!lat || !lon) {      return NextResponse.json(        { error: 'Latitude and Longitude are required' },        { status: 400 }      );    }    // Current weather endpoint    const currentUrl = `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&appid=${apiKey}&units=metric`;        // 5-day forecast endpoint (3-hour intervals)    const forecastUrl = `https://api.openweathermap.org/data/2.5/forecast?lat=${lat}&lon=${lon}&appid=${apiKey}&units=metric`;    // Fetch both current weather and forecast with proper typing    const [currentResponse, forecastResponse] = await Promise.all([      axios.get<CurrentWeatherResponse>(currentUrl),      axios.get<ForecastResponse>(forecastUrl)    ]);    // Process forecast data to group by day    const dailyForecasts = processForecastData(forecastResponse.data);    return NextResponse.json({      current: currentResponse.data,      forecast: dailyForecasts    }, {      status: 200,      headers: {         'Content-Type': 'application/json',        'X-RateLimit-Limit': limit.toString(),        'X-RateLimit-Remaining': remaining.toString(),        'X-RateLimit-Reset': reset.toString()      },    });  } catch (error: unknown) {    let errorMessage = 'Unknown error';    let errorDetails = {};    let status = 500;    if (axios.isAxiosError(error)) {      errorMessage = error.message;      errorDetails = error.response?.data || error.message;      status = error.response?.status || 500;      console.error('Error details:', errorDetails);    } else if (error instanceof Error) {      errorMessage = error.message;      console.error('Error message:', errorMessage);      errorDetails = error.message;      console.error('Error details:', error.message);    } else {      console.error('Error details:', error);    }    return NextResponse.json(      {         error: 'Failed to fetch weather data',        details: errorDetails       },      { status }    );  }}

Code explanation:

  • Calls OpenWeatherMap’s weather and forecast APIs.

  • Uses axios for HTTP requests.

  • Includes rate limiting to avoid abuse.

  • Uses processForecastData() to group and filter 3-hour forecasts into daily summaries.

  • Returns structured JSON with current and forecast data.

  • NB: Delete the app/page.tsx that’s there by default when you installed Next.js.

7. Build the Homepage and Forecast Section

Homepage of the App

The app/(root)/page.tsx file will be the main landing page of the app that displays a navigation bar, a hero section, and optionally, a list of recent searches (if the user is logged in).

Homepage of the App

Paste the following lines of code in the app/(root)/page.tsx file:

// app/(root)/page.tsx "use client"; import { useState } from "react"; import Navbar from "@/components/Navbar"; import Hero from "@/components/Hero"; import RecentSearches from "@/components/RecentSearches"; import { useRouter } from "next/navigation"; import { useUser } from "@clerk/nextjs"; // Import Clerk's useUser hook export default function Home() {   const router = useRouter();   const [mobileMenuOpen, setMobileMenuOpen] = useState(false);   const [tempUnit, setTempUnit] = useState<"C" | "F">("C");   const { isSignedIn } = useUser(); // Get authentication status   const handleRecentSearchClick = (id: string) => {     router.push(`/forecast/${id}`);   };   return (     <div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-blue-100 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900">       <Navbar         mobileMenuOpen={mobileMenuOpen}         setMobileMenuOpen={setMobileMenuOpen}         tempUnit={tempUnit}         setTempUnit={setTempUnit}       />             <Hero />     {/* Show RecentSearches only if user is authenticated */}       {isSignedIn && (         <RecentSearches           tempUnit={tempUnit}           onSearchClick={handleRecentSearchClick}         />       )}       <style jsx>{`         @keyframes float {           0%, 100% { transform: translateY(0px); }           50% { transform: translateY(-10px); }         }         @keyframes spin {           from { transform: rotate(0deg); }           to { transform: rotate(360deg); }         }       `}</style>     </div>   ); }

Code explanation:

  • Uses Clerk's useUser hook to check if a user is signed in.

  • Manages the temperature unit (Celsius or Fahrenheit) using useState. Users can switch the temperature unit from Celsius to Fahrenheit and back.

  • Displays the RecentSearches component only if the user is signed in.

  • Uses router.push() to navigate to /forecast/[id] when a recent search is clicked.

Display the Weather Forecast

The app/(root)/forecast/[id]/page.tsx file is the dynamic route that displays the weather forecast for a specific location, based on its latitude and longitude passed via the URL (e.g., /forecast/6.5--3.3).

Paste these lines of code in it:

// app/(root)/forecast/[id]/page.tsx
import { notFound } from 'next/navigation'; import WeatherDisplay from '@/components/WeatherDisplay'; import RefreshButton from '@/components/RefreshButton'; import { Suspense } from 'react'; import WeatherSkeleton from '@/components/WeatherSkeleton'; import Link from 'next/link'; interface ForecastPageProps {   params: { id: string };   searchParams: { refresh?: string }; } async function getWeatherData(lat: number, lon: number, forceRefresh = false) {   try {     const apiKey = process.env.OPENWEATHER_API_KEY;     if (!apiKey) throw new Error('API key not configured');         const url = `${process.env.NEXT_PUBLIC_BASE_URL}/api/weather?lat=${lat}&lon=${lon}&appid=${apiKey}`;         const response = await fetch(url, {       next: { revalidate: forceRefresh ? 0 : 3600 },     });         if (!response.ok) {       throw new Error(`Weather data fetch failed with status ${response.status}`);     }     return await response.json();   } catch (error) {     console.error('Failed to fetch weather data:', error);     throw error;   } } async function saveSearchHistory(locationName: string, lat: number, lon: number, temperature: number) {   try {     const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';     const response = await fetch(`${baseUrl}/api/create-history`, {       method: 'POST',       headers: {         'Content-Type': 'application/json',       },       body: JSON.stringify({         locationName,         lat,         lon,         temperature,       }),     });     if (!response.ok) {       throw new Error(`Failed to save search history: ${response.status}`);     }     return await response.json();   } catch (error) {     console.error('Error saving search history:', error);   } } export default async function ForecastPage({   params,   searchParams }: ForecastPageProps) {   const { id } = await params;   console.log('Forecast page params:', id);   const [lat, lon] = id.split('--').map(Number);     if (!lat || !lon || isNaN(lat) || isNaN(lon)) {     return notFound();   }   const awaitedSearchParams = await searchParams;   const forceRefresh = awaitedSearchParams.refresh === 'true';   try {     const weatherData = await getWeatherData(lat, lon, forceRefresh);     // Save search history after successful weather data fetch     await saveSearchHistory(       weatherData?.current?.name, // locationName from weather data       weatherData?.current?.coord?.lat, // lat from weather data       weatherData?.current?.coord?.lon, // lon from weather data       Math?.round(weatherData?.current?.main?.temp), // temperature rounded to nearest integer     );     return (       <div className="container mx-auto px-4 py-8">         <div className="flex justify-between items-center mb-6">           <Link className="text-2xl font-bold cursor-pointer" href="/">Weather Forecast</Link>           <RefreshButton />         </div>                 <Suspense fallback={<WeatherSkeleton />}>           <WeatherDisplay             initialData={weatherData}           />         </Suspense>       </div>     );   } catch (error) {     console.error('Error rendering forecast page:', error);     return notFound();   } }

Code explanation:

  • URL is parsed to extract lat and lon from the dynamic route param [id], using -- as a delimiter.

  • The weather data fetching calls  /api/weather?lat=X&lon=Y using the base URL and API key and uses forceRefresh based on the search param ?refresh=true.

  • After fetching weather data, it sends a POST request to /api/create-history and saves locationName, lat, lon, and temperature to the search history on BCMS.

  • The page then displays a link to go back to the homepage and a refresh button that calls the same page with ?refresh=true. It also displays the WeatherDisplay component (inside a Suspense is for loading support).

  • And then there’s the logic for catching any failure in fetching or rendering. It logs the error and shows the notFound() fallback.

Display the Weather Forecast

Loading UI for Forecast Page Rendering

The app/(root)/forecast/[id]/loading.tsx file is basically a loading UI used during the async rendering of the forecast page.

Paste the following lines of code in it:

// app/(root)/forecast/[id]/loading.tsx import { Loader2 } from "lucide-react"; function Loading() {     return (         <div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-blue-100 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900 p-4">         <div className="max-w-6xl mx-auto space-y-6">           {/* Header Skeleton */}           <div className="flex items-center justify-between">             <div className="h-12 w-64 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>             <div className="flex space-x-2">               <div className="h-10 w-10 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>               <div className="h-10 w-10 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>             </div>           </div>             {/* Main Card Skeleton */}           <div className="h-48 bg-gray-200 dark:bg-gray-700 rounded-xl animate-pulse"></div>             {/* Loading Spinner */}           <div className="flex items-center justify-center py-12">             <Loader2 className="w-12 h-12 animate-spin text-blue-500" />           </div>         </div>       </div>     );   }     export default Loading;

Code explanation:

It uses a tailwind-based UI skeleton to mimic the layout of the page and a spinning loader (<Loader2 /> from lucide-react) as a visual cue.

Loading UI

How does this all work together:

  1. The user lands on the homepage and searches for a city.

  2. The app navigates to /forecast/[lat]---[lon] with data.

  3. That page fetches weather info, displays it, and stores it in BCMS search history.

  4. If the user is signed in, the homepage shows previous searches via RecentSearches.

  5. If the user revisits the same route or refreshes, data is fetched again.

8. Core Utility Files Functionalities

The lib/rate-limiter.ts and lib/utils.ts files in the lib folder power performance, rate limiting, formatting, and data transformation for the weather app.

API Rate Limiting Utility

This file handles API rate limiting using Upstash Redis to prevent abuse of your server resources. Here, it’ll be used in your /api/weatherand /api/location routes to block spammy or abusive usage.

Paste these lines of code inside your lib/rate-limiter.ts file:

// lib/rate-limiter.ts
import { Ratelimit } from'@upstash/ratelimit';import { Redis } from'@upstash/redis';// Initialize rate limiter based on environmentlet rateLimiter: Ratelimit;if (process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN) {  // Production: Use Upstash Redis  rateLimiter = new Ratelimit({    redis: new Redis({      url: process.env.UPSTASH_REDIS_REST_URL,      token: process.env.UPSTASH_REDIS_REST_TOKEN,    }),    limiter: Ratelimit.slidingWindow(5, '10 s'), // 5 requests per 10 seconds    analytics: true,    prefix: 'weatherscope-rate-limit'  });} elseif (process.env.NODE_ENV === 'production') {  thrownewError('Upstash Redis is required in production');} else {  // Development: In-memory fallback  console.warn('Using in-memory rate limiter - for development only');  rateLimiter = {    limit: async (identifier: string) => ({        identifier,        success: true,        limit: 10,        remaining: 9,        reset: Date.now() + 10000// 10 seconds from now    }),  } as unknown as Ratelimit;}export { rateLimiter };

Code explanation:

  • When deployed, the app connects to Upstash Redis using UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN. (These key and URL were added in the .env file earlier on).

  • In production, it implements a sliding window rate limiter: max 5 requests per 10 seconds per IP and adds analytics and prefixes to namespace the keys (weatherscope-rate-limit).

  • In development mode (when Redis is not configured), it provides a mock in-memory rate limiter that always succeeds. This is to avoid being blocked during local testing.

  • Finally, this utility file exports a rateLimiter instance which is being imported and used in any API route to apply request throttling based on IP address.

Helper Utility

The lib/utils.ts file contains helper utilities for class management, forecast data transformation, and user-friendly formatting.

Paste these lines of code in the file:

// lib/utils.tsimport { clsx, type ClassValue } from "clsx"import { twMerge } from "tailwind-merge"import type {   ForecastResponse,   ProcessedDayForecast,   WeatherCondition } from "@/types";export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs))}// Helper function with proper typingexport function processForecastData(forecastData: ForecastResponse): ProcessedDayForecast[] {  const dailyData: Record<string, {    date: string;    temps: number[];    weather: WeatherCondition[];    humidity: number[];    wind: number[];  }> = {};  forecastData.list.forEach((item) => {    const date = new Date(item.dt * 1000).toLocaleDateString();        if (!dailyData[date]) {      dailyData[date] = {        date,        temps: [],        weather: [],        humidity: [],        wind: []      };    }    dailyData[date].temps.push(item.main.temp);    dailyData[date].weather.push(item.weather[0]);    dailyData[date].humidity.push(item.main.humidity);    dailyData[date].wind.push(item.wind.speed);  });  // Helper to find the most common weather condition by id  function getMostCommonWeather(weatherArr: WeatherCondition[]): WeatherCondition {    const countMap: Record<number, { count: number, weather: WeatherCondition }> = {};    for (const w of weatherArr) {      if (!countMap[w.id]) {        countMap[w.id] = { count: 1, weather: w };      } else {        countMap[w.id].count++;      }    }    // Find the weather id with the highest count    let max = 0;    let mostCommon: WeatherCondition = weatherArr[0];    for (const key in countMap) {      if (countMap[key].count > max) {        max = countMap[key].count;        mostCommon = countMap[key].weather;      }    }    return mostCommon;  }  // Process each day's data  return Object.values(dailyData).map(day => {    const mostCommonWeather = getMostCommonWeather(day.weather);    return {      date: day.date,      temp_min: Math.min(...day.temps),      temp_max: Math.max(...day.temps),      temp_avg: day.temps.reduce((a, b) => a + b, 0) / day.temps.length,      weather: mostCommonWeather,      humidity_avg: day.humidity.reduce((a, b) => a + b, 0) / day.humidity.length,      wind_avg: day.wind.reduce((a, b) => a + b, 0) / day.wind.length    };  }).slice(0, 5); // Return only next 5 days}export function formatTimeAgo(dateString: string): string {  const date = new Date(dateString);  const now = new Date();  const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);  if (seconds < 0) return "just now";  if (seconds < 60) return "less than a minute ago";  const minutes = Math.floor(seconds / 60);  if (minutes < 60) return `${minutes} min${minutes === 1 ? '' : 's'} ago`;  const hours = Math.floor(minutes / 60);  if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`;  const days = Math.floor(hours / 24);  return `${days} day${days === 1 ? '' : 's'} ago`;}export const formatTime = (timestamp: number) => {  return new Date(timestamp * 1000).toLocaleTimeString("en-US", {    hour: "2-digit",    minute: "2-digit",    hour12: true,  });};export const getWindDirection = (deg: number) => {  const directions = [    "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",    "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"  ];  return directions[Math.round(deg / 22.5) % 16];};

Code explanation:

  • The processForecastData(forecastData) function transforms raw 3-hour forecast data from OpenWeather into daily summaries for 5 days and is used in /api/weather to convert raw data before sending it to the frontend. For each day:

    • It aggregates temps, humidity, wind, and weather conditions.

    • Calculates average, min, max values.

    • Picks the most common weather condition to represent that day (via getMostCommonWeather()).

  • It then returns an array of simplified ProcessedDayForecast[] objects used in the UI.

  • The formatTimeAgo(dateString function converts a timestamp into a relative time (e.g., “5 mins ago”, “2 hours ago”).

  • The formatTime(timestamp) function converts a Unix timestamp into a readable time string in hh:mm AM/PM format.

  • The getWindDirection(deg) function converts wind direction in degrees (0–360°) into cardinal directions like “N”, “NE”, “SW”.

9.png

9. Structure and Contracts When App Interacts With APIs

TypeScript Interfaces for API Responses, Weather Structure, & Processed Forecast

The types/index.ts file centralizes TypeScript interfaces used across your app for:

  • API responses (current weather response, forecast item, forecast response)

  • Weather structure (weather condition, main weather data, and wind date)

  • Processed forecast (processed day forecast, recent search, and recent searches props).

Paste these lines of code inside the file:

// types/index.ts
exportinterface WeatherCondition {  id: number;  main: string;  description: string;  icon: string;}exportinterface MainWeatherData {  temp: number;  feels_like: number;  temp_min: number;  temp_max: number;  pressure: number;  humidity: number;  sea_level?: number;  grnd_level?: number;}exportinterface WindData {  speed: number;  deg: number;  gust?: number;}exportinterface CurrentWeatherResponse {  coord: {    lon: number;    lat: number;  };  weather: WeatherCondition[];  base: string;  main: MainWeatherData;  visibility: number;  wind: WindData;  clouds: {    all: number;  };  rain?: {    '1h'?: number;    '3h'?: number;  };  snow?: {    '1h'?: number;    '3h'?: number;  };  dt: number;  sys: {    type?: number;    id?: number;    country: string;    sunrise: number;    sunset: number;  };  timezone: number;  id: number;  name: string;  cod: number;}exportinterface ForecastItem {  dt: number;  main: MainWeatherData;  weather: WeatherCondition[];  clouds: {    all: number;  };  wind: WindData;  visibility: number;  pop: number;  rain?: {    '3h'?: number;  };  snow?: {    '3h'?: number;  };  sys: {    pod: string;  };  dt_txt: string;}exportinterface ForecastResponse {  cod: string;  message: number;  cnt: number;  list: ForecastItem[];  city: {    id: number;    name: string;    coord: {      lat: number;      lon: number;    };    country: string;    population: number;    timezone: number;    sunrise: number;    sunset: number;  };}exportinterface ProcessedDayForecast {  date: string;  temp_min: number;  temp_max: number;  temp_avg: number;  weather: WeatherCondition;  humidity_avg: number;  wind_avg: number;}exportinterface RecentSearch {  id: string;  name: string;  temp: string;  timestamp: string;  lat: number;  lon: number;}exportinterface RecentSearchesProps {  tempUnit: "C" | "F";  onSearchClick: (name: string) => void;}

TypeScript Interfaces for Weather Data and API Response Components

The types/weather.ts file defines the TypeScript types specifically used in the weather display and API response components of the Weather App. 

These types are essential for ensuring correct data flow and structure in components and API functions.

Paste these lines of code inside the file:

// types/weather.ts export interface WeatherCondition {   id: number;   main: string;   description: string;   icon: string; } export interface MainWeatherData {   temp: number;   feels_like: number;   temp_min: number;   temp_max: number;   pressure: number;   humidity: number;   sea_level?: number;   grnd_level?: number; } export interface WindData {   speed: number;   deg: number;   gust?: number; } export interface CurrentWeatherResponse {   coord: {     lon: number;     lat: number;   };   weather: WeatherCondition[];   base: string;   main: MainWeatherData;   visibility: number;   wind: WindData;   clouds: {     all: number;   };   dt: number;   sys: {     country: string;     sunrise: number;     sunset: number;   };   name: string; } export interface ForecastDay {   date: string;   temp_min: number;   temp_max: number;   temp_avg: number;   weather: WeatherCondition;   humidity_avg: number;   wind_avg: number; } export interface WeatherApiResponse {   current: CurrentWeatherResponse;   forecast: ForecastDay[]; } export interface WeatherDisplayProps {   initialData: WeatherApiResponse; }

Code explanation:

  • The WeatherCondition interface represents a single weather condition reported by the OpenWeather API. It is useful for displaying the appropriate icon and descriptive text in the UI.

  • The MainWeatherData interface describes the temperature-related measurements. The values come from the main field in the OpenWeather response and help display detailed weather stats.

  • The WindData interface captures wind-related weather metrics. It is useful for showing wind speed and its direction using arrow icons or compass directions.

  • The CurrentWeatherResponse interface is the full response structure for current weather data. It is used for the current weather card in the app.

  • The ForecastDay interface is a simplified structure used after processing the raw 3-hour forecast into a single daily summary. It helps summarize and display the 5-day forecast in an understandable way.

  • The WeatherApiResponse interface is the structure returned by the /api/weather endpoint, which combines both current and forecast data.

  • The WeatherDisplayProps interface is used to type the props passed to the WeatherDisplay component and it ensures the weather display component receives structured and complete weather information on render.

10. Components Used Throughout the App

For the following components, refer to the respective GitHub repo page to copy and paste the lines of code into your own component files:

Once you’ve pasted the lines of code into your own code, run your app or refresh your browser to see your weather app in action.

Full demo of the weather application:

weather application demo

Try searching locations without signing up. Then sign up and search to see that your searches are being saved both in the BCMS dashboard and in the home page of the weather app.

Conclusion: With BCMS, you can create a weather app that will work as good as Accuweather

Let’s quickly recap what we’ve achieved and why this tech stack is a great choice for modern app development.

We built a modern, full-stack Weather App using Next.js, TypeScript, Tailwind CSS, and BCMS (as a headless CMS). The app allows users to:

  • Search for any location and view real-time weather data and 5-day forecasts.

  • Store a personal search history in BCMS to enable you to save and manage large datasets without hardcoding them into your frontend.

  • Enjoy a fast, mobile-friendly experience with smooth UI.

Are you ready to dive in?

Feel free to clone the GitHub repo and try it for yourself. You can also customize the design or add new features.

Happy coding!

It takes a minute to start using BCMS

Gradient

Join our Newsletter

Get all the latest BCMS updates, news and events.

You’re in!

The first mail will be in your inbox next Monday!
Until then, let’s connect on Discord as well:

Join BCMS community on Discord

By submitting this form you consent to us emailing you occasionally about our products and services. You can unsubscribe from emails at any time, and we will never pass your email to third parties.

Gradient