It’s been a while since I shared a fun project, so here’s something I whipped up over the weekend! This app is called the World Mood-O-Meter (Try It Here). The idea is really simple - we figure out the mood of a country based on real-time news. Using data fetched with SerpApi, we run sentiment analysis on the news headlines with the help of an AI model (I’m using GPT-4 via OpenRouter). If you just want to build this project without spending anything, feel free to grab any model from HuggingFace!
Important Tools
SerpApi
SerpApi is a powerful real-time API designed to fetch Google search results and data from other search engines and websites. It takes care of the heavy lifting, like managing proxies, solving CAPTCHAs, and parsing data into an organized, easy-to-use format.
With SerpApi, developers can interact directly with search engines and access structured search results that are ready for analysis or integration into applications. This makes it ideal for SEO monitoring, market research, or competitive analysis tasks. The best part? You don’t need to worry about building or maintaining complex web scraping systems—SerpApi does everything for you.
I love that I can get access to all this below information from a single place, with very easy implementation.
OpenRouter
OpenRouter is an AI platform that offers seamless access to a variety of advanced AI models from providers like OpenAI, Anthropic, and Google. Using its OpenAI-compatible completion API, you can easily integrate state-of-the-art models into your projects without additional setup.
One standout feature of OpenRouter is its ability to route your requests to the best-suited AI model for your task. It also supports structured outputs, ensuring the responses are always clean and follow the required format. Whether developing AI-driven applications or exploring new AI capabilities, OpenRouter makes the integration process simple and scalable.
My favorite thing is on-demand credits top-up - so it doesn’t empty my bank!
Understanding World Mood-O-Meter
The World Mood-O-Meter pulls real-time news from the web (thanks to SerpApi’s Google News API) and uses AI-powered sentiment analysis to figure out the country’s current vibe. Is it positive, neutral, or negative?
Why Build This?
Because it’s fun!! This project combines a mix of APIs, data processing, and AI in a simple, practical way. Plus, it’s just plain cool to see real-time mood updates for different countries!
Implementation
We’re building this app using Next.js (App Router) along with TypeScript and ShadCN UI for styling. Let’s dive into each step, breaking down the files and explaining what’s happening.
1. Setting Up the Project
Start by creating your Next.js project. Open your terminal and run:
npx create-next-app@latest
When prompted, choose TypeScript
and App Router
to set up your app with the latest features.
Once your app is created, you'll need to add ShadCN components for reusable UI elements. Run these commands to get started:
npx shadcn@latest init
npx shadcn@latest add card
npx shadcn@latest add input
npx shadcn@latest add command
npx shadcn@latest add popover
npx shadcn@latest add button
npx shadcn@latest add scroll-area
npx shadcn@latest add alert
npx shadcn@latest add badge
npx shadcn@latest add skeleton
Next, install the additional dependencies for your project:
npm install react-svg-worldmap lucide-react
And that's it! Your project is now set up with all the tools you'll need to build the World Mood-O-Meter.
2. Setting Up Environment Variables
We need two API keys: one for SerpAPI (here) to fetch news and another for OpenRouter to use GPT-4 for sentiment analysis. Add these to your .env.local
file (this file should be in your root folder):
# SerpAPI Key
SERPAPI_KEY=your-serpapi-key
# OpenRouter Key
NEXT_PUBLIC_OPENROUTER_KEY=your-openrouter-key
Replace your-serpapi-key
and your-openrouter-key
with your actual keys. These keys are essential for the app to function properly.
3. Fetch News API (app/api/news/route.ts
)
This file contains the backend API route that fetches real-time news for a specific country using SerpAPI. Here’s what’s happening:
Receive the Country Name: When the user selects a country on the map, the frontend sends a POST request with the country name.
Fetch News: We use SerpAPI's Google News engine to fetch the latest news headlines for that country. It handles all the tricky stuff like CAPTCHA solving and parsing the data.
Return Results: The API returns the news in a structured format, which the front end can easily display.
import { NextResponse } from "next/server";
export async function POST(request: Request) {
try {
const { country } = await request.json();
if (!country) {
return NextResponse.json(
{ error: "Country is required" },
{ status: 400 }
);
}
const params: Record<string, string> = {
engine: "google_news",
q: `${country} news`,
gl: "us",
hl: "en",
api_key: process.env.SERPAPI_KEY || "",
};
console.log("API Key available:", !!process.env.SERPAPI_KEY);
const searchParams = new URLSearchParams(params);
const url = `https://serpapi.com/search?${searchParams.toString()}`;
const debugUrl = url.replace(
process.env.SERPAPI_KEY || "",
"[API_KEY]"
);
console.log("Calling URL:", debugUrl);
const response = await fetch(url);
console.log("SerpAPI Response Status:", response.status);
if (!response.ok) {
const errorText = await response.text();
console.error("SerpAPI Error:", errorText);
throw new Error(
`SerpAPI returned ${response.status}: ${errorText}`
);
}
const data = await response.json();
if (!data.news_results) {
console.log("Response data structure:", Object.keys(data));
throw new Error("No news results in response");
}
return NextResponse.json({
news_results: data.news_results,
});
} catch (error) {
console.error("Detailed error:", error);
return NextResponse.json(
{
error: "Error fetching news",
},
{ status: 500 }
);
}
}
4. Main Page (app/page.tsx
)
The main page of our app renders the MoodMap
component, which displays the interactive world map. This is the entry point for users.
import MoodMap from "@/components/MoodMap";
export default function Home() {
return (
<main className="min-h-screen p-4">
<MoodMap />
</main>
);
}
5. Mood Map Component (components/MoodMap.tsx
)
This is the heart ❤️ of our app. The MoodMap
component:
Displays the World Map: Users can click on any country.
Handles Search: Allows users to search for a country by name.
Fetches News: Calls the
/api/news
route to get the latest news.Analyzes Sentiment: Sends the news headlines to OpenRouter to analyze the mood.
Here’s how it works:
State Management: We use React state hooks to track selected country, news data, mood, and loading status.
Interactive Map: The
react-svg-worldmap
library creates a clickable map.Fetching News: When a country is clicked, we fetch its news and analyze the mood using GPT-4.
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Globe, AlertCircle, Newspaper, ExternalLink } from "lucide-react";
interface NewsItem {
title: string;
snippet: string;
link: string;
}
interface NewsSectionProps {
selectedCountry: string | null;
loading: boolean;
mood: string | null;
news: NewsItem[];
moodExplanation: string;
}
const NewsSection = ({ selectedCountry, loading, mood, news, moodExplanation }: NewsSectionProps) => {
const getMoodEmoji = (currentMood: string | null) => {
const moods = {
positive: "😊",
negative: "😔",
neutral: "😐"
};
return moods[currentMood as keyof typeof moods] || "🤔";
};
const getMoodColor = (currentMood: string | null) => {
const colors = {
positive: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
negative: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
neutral: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
};
return colors[currentMood as keyof typeof colors] || "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200";
};
if (!selectedCountry) {
return (
<Card className="backdrop-blur-sm bg-white/50 dark:bg-gray-800/50 border-0 shadow-xl">
<CardContent className="p-12">
<div className="flex flex-col items-center justify-center space-y-4">
<div className="w-16 h-16 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
<Globe className="h-8 w-8 text-blue-600 dark:text-blue-400" />
</div>
<h3 className="text-xl font-semibold text-gray-800 dark:text-gray-200">Select a Country</h3>
<p className="text-gray-600 dark:text-gray-400 text-center max-w-sm">
Choose a country from the map or use the search dropdown to analyze its current mood
</p>
</div>
</CardContent>
</Card>
);
}
if (loading) {
return (
<Card className="backdrop-blur-sm bg-white/50 dark:bg-gray-800/50 border-0 shadow-xl">
<CardHeader>
<Skeleton className="h-8 w-40" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-20 w-full" />
{Array(3).fill(0).map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</CardContent>
</Card>
);
}
return (
<Card className="backdrop-blur-sm bg-white/50 dark:bg-gray-800/50 border-0 shadow-xl">
<CardHeader className="border-b border-gray-100 dark:border-gray-700">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<span className="text-xl">{selectedCountry}</span>
<span className="text-2xl">{getMoodEmoji(mood)}</span>
</CardTitle>
<Badge variant="outline" className={getMoodColor(mood)}>
{mood?.toUpperCase() || "UNKNOWN"}
</Badge>
</div>
</CardHeader>
<CardContent className="p-6">
<ScrollArea className="h-[600px] pr-4">
<div className="space-y-6">
<Alert className={`${getMoodColor(mood)} border-2`}>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="ml-2 font-medium">{moodExplanation}</AlertDescription>
</Alert>
<div className="space-y-4">
<div className="flex items-center gap-2">
<Newspaper className="h-5 w-5 text-gray-600 dark:text-gray-400" />
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200">Latest Headlines</h3>
</div>
{news.length > 0 ? (
<div className="grid gap-4">
{news.map((item, index) => (
<Card key={index} className="group hover:shadow-md transition-all duration-200">
<CardContent className="p-4">
<h4 className="font-semibold text-gray-800 dark:text-gray-200 mb-2">{item.title}</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{item.snippet}</p>
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 text-sm font-medium group"
>
Read more
<ExternalLink className="ml-1 h-4 w-4 group-hover:translate-x-0.5 transition-transform duration-150" />
</a>
</CardContent>
</Card>
))}
</div>
) : (
<p className="text-gray-500 dark:text-gray-400 text-center py-8">
No news found for this country.
</p>
)}
</div>
</div>
</ScrollArea>
</CardContent>
</Card>
);
};
export default NewsSection;
6. News Section Component (components/NewsSection.tsx
)
The NewsSection
component takes the fetched news and mood analysis and displays them beautifully. Here’s what it does:
Show Mood and Explanation: The app uses GPT-4 to analyze the sentiment and provides an emoji to represent the mood.
Display News Headlines: It lists the latest news articles for the selected country.
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Globe, AlertCircle, Newspaper, ExternalLink } from "lucide-react";
interface NewsItem {
title: string;
snippet: string;
link: string;
}
interface NewsSectionProps {
selectedCountry: string | null;
loading: boolean;
mood: string | null;
news: NewsItem[];
moodExplanation: string;
}
const NewsSection = ({ selectedCountry, loading, mood, news, moodExplanation }: NewsSectionProps) => {
const getMoodEmoji = (currentMood: string | null) => {
const moods = {
positive: "😊",
negative: "😔",
neutral: "😐"
};
return moods[currentMood as keyof typeof moods] || "🤔";
};
const getMoodColor = (currentMood: string | null) => {
const colors = {
positive: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
negative: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
neutral: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
};
return colors[currentMood as keyof typeof colors] || "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200";
};
if (!selectedCountry) {
return (
<Card className="backdrop-blur-sm bg-white/50 dark:bg-gray-800/50 border-0 shadow-xl">
<CardContent className="p-12">
<div className="flex flex-col items-center justify-center space-y-4">
<div className="w-16 h-16 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
<Globe className="h-8 w-8 text-blue-600 dark:text-blue-400" />
</div>
<h3 className="text-xl font-semibold text-gray-800 dark:text-gray-200">Select a Country</h3>
<p className="text-gray-600 dark:text-gray-400 text-center max-w-sm">
Choose a country from the map or use the search dropdown to analyze its current mood
</p>
</div>
</CardContent>
</Card>
);
}
if (loading) {
return (
<Card className="backdrop-blur-sm bg-white/50 dark:bg-gray-800/50 border-0 shadow-xl">
<CardHeader>
<Skeleton className="h-8 w-40" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-20 w-full" />
{Array(3).fill(0).map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</CardContent>
</Card>
);
}
return (
<Card className="backdrop-blur-sm bg-white/50 dark:bg-gray-800/50 border-0 shadow-xl">
<CardHeader className="border-b border-gray-100 dark:border-gray-700">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<span className="text-xl">{selectedCountry}</span>
<span className="text-2xl">{getMoodEmoji(mood)}</span>
</CardTitle>
<Badge variant="outline" className={getMoodColor(mood)}>
{mood?.toUpperCase() || "UNKNOWN"}
</Badge>
</div>
</CardHeader>
<CardContent className="p-6">
<ScrollArea className="h-[600px] pr-4">
<div className="space-y-6">
<Alert className={`${getMoodColor(mood)} border-2`}>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="ml-2 font-medium">{moodExplanation}</AlertDescription>
</Alert>
<div className="space-y-4">
<div className="flex items-center gap-2">
<Newspaper className="h-5 w-5 text-gray-600 dark:text-gray-400" />
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200">Latest Headlines</h3>
</div>
{news.length > 0 ? (
<div className="grid gap-4">
{news.map((item, index) => (
<Card key={index} className="group hover:shadow-md transition-all duration-200">
<CardContent className="p-4">
<h4 className="font-semibold text-gray-800 dark:text-gray-200 mb-2">{item.title}</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{item.snippet}</p>
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 text-sm font-medium group"
>
Read more
<ExternalLink className="ml-1 h-4 w-4 group-hover:translate-x-0.5 transition-transform duration-150" />
</a>
</CardContent>
</Card>
))}
</div>
) : (
<p className="text-gray-500 dark:text-gray-400 text-center py-8">
No news found for this country.
</p>
)}
</div>
</div>
</ScrollArea>
</CardContent>
</Card>
);
};
export default NewsSection;
Final Directory Structure
After completing the setup, your project directory structure will look like this:
📂 internet-mood
├── 📁 app
│ ├── 📁 api
│ │ └── 📁 news
│ │ └── route.ts
│ ├── 📜 layout.tsx
│ ├── 📜 page.tsx
├── 📁 components
│ ├── 📜 MoodMap.tsx
│ ├── 📜 NewsSection.tsx
├── 📁 lib
│ ├── 📜 utils.ts
├── 📁 public
│ ├── (SVG and public assets)
├── 📜 .env.local
├── 📜 tailwind.config.ts
└── Other project configuration files...
Conclusion:
That’s it—your World Mood-O-Meter is ready to show the vibe of the world, one country at a time! 🚀
If you liked this tutorial - do give it some love by liking and sharing ♥️ and follow me on X!