Build an AI Energy Intelligence Map

Score energy projects with GPT-4o-mini and plot them on an interactive map.

Introduction

30 Second Summary

Energy companies track hundreds of infrastructure projects across different regions. Without a way to quickly spot which ones are worth pursuing, analysts waste hours manually reviewing each project one by one.

In this project, you will build Energy Radar, an AI-powered intelligence platform that scores energy projects and plots them on an interactive, color-coded map.

What You'll Build

You will have a live dashboard where you feed in energy project data, watch GPT-4o-mini score each project in seconds, and explore the results on a full-screen map with green, yellow, and red markers that reveal opportunity at a glance.

By the end of this project, you'll have:

  • A full-screen interactive map of energy projects, color-coded by AI-assigned opportunity score. High-opportunity projects glow green, medium ones yellow, and low ones red.
  • An AI scoring pipeline powered by OpenAI Structured Outputs that guarantees consistent JSON every time. Your API key stays secure on the server thanks to a Next.js Route Handler.
  • A click-to-inspect detail panel that slides in and reveals the AI's evidence and reasoning for any project marker you click on the map.
  • Secret Mission: Add filtering controls to narrow the map by energy type and minimum opportunity score.

Are there any prerequisites?

You need Node.js 22+ installed, VS Code ready to go, and an OpenAI API key with credit available.

No prior experience with Next.js or mapping libraries is required. This project walks you through everything from scratch.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Scaffold the Project and Install Dependencies

You have a clear picture of what Energy Radar does. Now you need a working development environment before you can build anything.

In this step you will scaffold a Next.js project, install the OpenAI SDK and mapping libraries, and confirm the dev server runs. Getting the scaffold up first proves your toolchain works end to end.

In this step, get ready to:
  • Create a Next.js project with TypeScript, Tailwind CSS, and App Router.
  • Install the OpenAI SDK and React Leaflet mapping libraries.
  • Store your API key and verify the dev server loads.
Create the Next.js project

The create-next-app CLI scaffolds a complete Next.js project with one command. It asks whether you want to use the recommended defaults or customise each option individually.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold the project by running this command:
npx create-next-app@latest energy-radar

What will this command ask?

The CLI will ask you one key question:

Would you like to use the recommended Next.js defaults?
    Yes, use recommended defaults - TypeScript, ESLint, Tailwind CSS, App Router, AGENTS.md
    No, reuse previous settings
    No, customize settings - Choose your own preferences

Select the first option: Yes, use recommended defaults. This gives you TypeScript, ESLint, Tailwind CSS, and App Router out of the box.

  • Select Yes, use recommended defaults when prompted.
  • Wait for the install to finish. You will see a success message once the project is created.
  • Move into the new project folder by running:
cd energy-radar
  • Confirm the project files exist by running:
ls

You should see files including package.json, next.config.ts, tsconfig.json, and an app folder.

Command not found or permission error?

Make sure you have Node.js 22+ installed by running node --version. If the version is lower than 22, update Node.js before continuing.

If you see a permission error on macOS, try prefixing the command with sudo. Help me fix a create-next-app permission error

Install the project dependencies

The scaffold gives you Next.js and React. You still need the OpenAI SDK for AI calls, and React Leaflet plus Leaflet for the interactive map.

  • Install the runtime packages by running this command:
npm install openai react-leaflet leaflet
  • Install the TypeScript type definitions for Leaflet by running:
npm install -D @types/leaflet

Why two separate install commands?

The -D flag saves @types/leaflet as a dev dependency. It provides TypeScript autocomplete and type checking for Leaflet, but it does not ship in your production bundle.

Both installs should finish with no errors. You will see an added X packages summary at the end.

Seeing peer dependency warnings?

Warnings about peer dependencies are normal and safe to ignore. As long as you do not see a red ERR! line, your install succeeded.

Help me resolve npm install errors for this project

Store your API key and start the dev server

Your OpenAI API key needs to live in an environment file so the server can read it without exposing it in code.

  • Open VS Code. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to Desktop, select the energy-radar folder, and click Open.
  • Create a new file called .env.local in the project root. Click the new file icon in the Explorer sidebar and name it .env.local.
  • Paste the following into .env.local:
OPENAI_API_KEY=your-api-key-here
  • Replace your-api-key-here with your actual OpenAI API key, then save the file.

Why .env.local?

Next.js automatically reads .env.local on the server side. Your API key stays on the server and never reaches the browser. This file is also gitignored by default, so it will not be committed to version control.

Before you start the dev server, what do you expect to see in the browser? Think about whether you have written any custom code yet.

  • Open the VS Code integrated terminal by pressing Ctrl+` (backtick key).
  • Start the development server by running:
npm run dev
  • Open your browser and navigate to http://localhost:3000.

You should see the default Next.js starter page with the Next.js logo and links to documentation. This confirms your scaffold, dependencies, and environment are all working.

Page not loading at localhost:3000?

Check your terminal for error messages. A common issue is port 3000 already being in use. If that happens, Next.js will suggest a different port in the terminal output.

If you see a module-not-found error, run npm install again to make sure all dependencies are present.

Help me debug my Next.js dev server not starting

✔️ Awesome, I've got everything!

Great. Double check that you have saved .env.local and that your dev server is still running in the terminal.

ⓧ I'd like to double check the full code

At this point your only hand-written file is .env.local. Confirm it matches this:

OPENAI_API_KEY=your-api-key-here

The scaffold also generated app/layout.tsx. Confirm it looks like this:

import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "Energy Radar",
  description: "AI-powered energy infrastructure intelligence platform",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className="antialiased">{children}</body>
    </html>
  );
}

Your environment is locked in. The dev server runs, your packages are installed, and your API key is secure. Next up, you will load energy project data and get it on screen.

Load Seed Data and Display a Raw Project List

Your Next.js environment is running and the default starter page confirms everything works.

Before touching AI or maps, you need data on screen. This step loads 8 energy project records and displays them as plain cards. The rawness is intentional. By the end, you will feel the limitation firsthand: a flat list of projects tells you nothing about which ones are worth pursuing.

In this step, get ready to:
  • Create a seed data file with 8 energy projects spanning solar, wind, battery, and hydrogen types.
  • Build a Server Component that reads the JSON and renders each project as a Tailwind-styled card.
  • Confirm all 8 projects render in the browser and notice the problem: no way to tell which ones matter.
Create the seed data file

Energy Radar needs project data to analyze. You will create a JSON file containing 8 real-world-style energy infrastructure projects, each with a name, type, geographic coordinates, and capacity.

  • In VS Code's Explorer sidebar, right-click the app folder.
  • Select New File.
  • Type data/projects.json and press Enter. VS Code creates the data folder and the file automatically.
  • Paste the first section of project data into the empty file (projects 1-3):
[
  {
    "id": "proj-001",
    "name": "Sunrise Solar Farm",
    "type": "solar",
    "description": "150MW utility-scale solar farm proposed for degraded agricultural land in Nevada. Environmental impact assessment completed. Grid connection agreement pending.",
    "latitude": 36.17,
    "longitude": -115.14,
    "capacity": "150 MW"
  },
  {
    "id": "proj-002",
    "name": "Atlantic Wind Corridor",
    "type": "wind",
    "description": "Offshore wind development zone identified 30km off the coast of Virginia. Early-stage feasibility study underway. Strong wind resource data from NOAA buoys.",
    "latitude": 36.85,
    "longitude": -75.28,
    "capacity": "800 MW"
  },
  {
    "id": "proj-003",
    "name": "GreenGrid Battery Hub",
    "type": "battery",
    "description": "Grid-scale lithium-ion battery storage facility adjacent to existing substation in Texas. Planning permission granted. Construction tender phase.",
    "latitude": 32.78,
    "longitude": -96.80,
    "capacity": "200 MWh"
  },

What does this data represent?

Each project object has an id, a human-readable name, and a type (solar, wind, battery, or hydrogen). The description contains signals about regulatory progress, funding, and development stage.

The latitude and longitude fields will power the map later. For now they just sit in the data file unused.

  • Continue pasting directly below the last comma. Add projects 4-6:
  {
    "id": "proj-004",
    "name": "Desert Hydrogen Plant",
    "type": "hydrogen",
    "description": "Green hydrogen production facility powered by co-located solar. Pilot phase with 5MW electrolyzer. Offtake agreement with regional gas utility under negotiation.",
    "latitude": 33.45,
    "longitude": -112.07,
    "capacity": "5 MW electrolyzer"
  },
  {
    "id": "proj-005",
    "name": "Lakeview Community Solar",
    "type": "solar",
    "description": "Community solar garden serving 500 residential subscribers in Minnesota. Interconnection study complete. Awaiting final municipal permits.",
    "latitude": 44.98,
    "longitude": -93.27,
    "capacity": "10 MW"
  },
  {
    "id": "proj-006",
    "name": "Prairie Wind Expansion",
    "type": "wind",
    "description": "Phase 2 expansion of existing operational wind farm in Oklahoma. 20 additional turbines planned. Power purchase agreement signed with corporate buyer.",
    "latitude": 35.47,
    "longitude": -97.52,
    "capacity": "60 MW"
  },
  • Finish the file by pasting the final two projects and the closing bracket:
  {
    "id": "proj-007",
    "name": "Pacific Storage Array",
    "type": "battery",
    "description": "Flow battery demonstration project in Oregon targeting long-duration storage. DOE grant awarded. Technology validation stage with novel vanadium chemistry.",
    "latitude": 45.52,
    "longitude": -122.68,
    "capacity": "50 MWh"
  },
  {
    "id": "proj-008",
    "name": "Midwest Hydrogen Network",
    "type": "hydrogen",
    "description": "Regional hydrogen pipeline network connecting three production sites to industrial consumers in Ohio. Pre-FEED engineering study commissioned. DOE Hydrogen Hub applicant.",
    "latitude": 39.96,
    "longitude": -82.99,
    "capacity": "100 tons/day"
  }
]
  • Save app/data/projects.json with Cmd+S (macOS) or Ctrl+S (Windows).
  • Confirm the file appears in the Explorer sidebar under app > data > projects.json.

Don't see the data folder?

Make sure you typed data/projects.json (with the forward slash) when creating the file. If you accidentally created a flat file named data, delete it and try again.

Also check that the JSON has a matching opening [ and closing ] bracket. If you see a red underline, you likely have a trailing comma after the last project or a missing bracket.

Need help? Why is my projects.json showing a syntax error?

✔️ Awesome, I've got everything!

Great. Double check that your file starts with [ and ends with ], with 8 project objects inside.

ⓧ I'd like to double check the full code

The file is made up of the three code blocks you pasted above, in order. Scroll up and confirm:

  • Block 1 starts with [ and contains projects proj-001 through proj-003.
  • Block 2 contains projects proj-004 through proj-006.
  • Block 3 contains projects proj-007 and proj-008, ending with ] (no trailing comma on the last project).
Display projects as Tailwind-styled cards

In the Next.js App Router, every component in the app/ directory is a Server Component by default. That means it can import JSON files directly with no extra fetch logic.

  • Open app/page.tsx in VS Code (it already exists from the scaffold).
  • Select all the existing content and replace it with the following:
import projects from "./data/projects.json";

export default function Home() {
  return (
    <main className="p-8 bg-gray-950 min-h-screen">
      <h1 className="text-3xl font-bold text-white mb-6">Energy Radar</h1>
      <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
        {projects.map((project) => (
          <div key={project.id} className="bg-gray-800 rounded-lg p-4 text-white">
            <h2 className="text-xl font-semibold">{project.name}</h2>
            <p className="text-sm text-gray-400 uppercase mt-1">{project.type}</p>
            <p className="text-sm text-gray-300 mt-2">{project.description}</p>
            <p className="text-sm text-gray-400 mt-2">Capacity: {project.capacity}</p>
          </div>
        ))}
      </div>
    </main>
  );
}

What does this code do?

The import projects from "./data/projects.json" line loads your seed data at build time. No API call needed because this is a Server Component.

The projects.map() iterates over all 8 projects and renders a card for each one. Each card shows the name, type, description, and capacity using Tailwind CSS utility classes for dark-themed styling.

Before you check the browser, what do you expect to see? Think about what 8 project cards with no scoring or map would look like.

  • Save app/page.tsx.
  • Switch to your browser at http://localhost:3000. The page auto-refreshes.

You should see a dark page with the title "Energy Radar" and 8 project cards arranged in a two-column grid. Each card shows a project name, its type in uppercase, a description paragraph, and the capacity.

Seeing an error instead of cards?

If you see a "Module not found" error pointing at ./data/projects.json, confirm that your file is saved at exactly app/data/projects.json (inside the app folder, not the project root).

If the page loads but shows no cards, open your browser's DevTools console. A JSON parse error means your projects.json has invalid syntax. Check for trailing commas or mismatched brackets.

Still stuck? Help me debug why my projects are not showing up on the page.

✔️ Awesome, I've got everything!

Great. Confirm you see all 8 cards and that the file is saved.

ⓧ I'd like to double check the full code

Your app/page.tsx should look exactly like this:

import projects from "./data/projects.json";

export default function Home() {
  return (
    <main className="p-8 bg-gray-950 min-h-screen">
      <h1 className="text-3xl font-bold text-white mb-6">Energy Radar</h1>
      <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
        {projects.map((project) => (
          <div key={project.id} className="bg-gray-800 rounded-lg p-4 text-white">
            <h2 className="text-xl font-semibold">{project.name}</h2>
            <p className="text-sm text-gray-400 uppercase mt-1">{project.type}</p>
            <p className="text-sm text-gray-300 mt-2">{project.description}</p>
            <p className="text-sm text-gray-400 mt-2">Capacity: {project.capacity}</p>
          </div>
        ))}
      </div>
    </main>
  );
}
Notice the problem

Scroll through the cards in your browser. You have 8 projects spanning four energy types, multiple US states, and wildly different capacities.

Now ask yourself: which of these projects should an analyst prioritize? Which ones have the most regulatory momentum? Which are risky early-stage bets? The raw list gives you no way to answer these questions. That gap is exactly what AI scoring will solve in the next step.

Your seed data is on screen, but it is just a wall of text. Next up, you will build a Route Handler that sends each project to GPT-4o-mini and gets back a structured opportunity score with evidence.

Build the AI Scoring Route Handler

Your homepage shows 8 energy projects, but they're just raw data. You can't tell which ones are worth pursuing, which are stalled, or where the best opportunities hide.

In this step, you'll build a Route Handler that sends each project to gpt-4o-mini and gets back a structured opportunity score (1-10) with supporting evidence. OpenAI Structured Outputs guarantee the response always matches your schema.

In this step, get ready to:
  • Define TypeScript interfaces for raw and analyzed projects.
  • Build a Route Handler that calls GPT-4o-mini with Structured Outputs.
  • Test the endpoint and verify structured JSON responses.
Define the project types

Before building the Route Handler, you need TypeScript interfaces that describe the shape of your data. One interface covers the raw project data, and another extends it with the AI-generated fields.

  • Create a new file called app/types.ts in VS Code by right-clicking the app folder in the Explorer sidebar and selecting New File.
  • Add the following type definitions:
export interface EnergyProject {
  id: string;
  name: string;
  type: "solar" | "wind" | "battery" | "hydrogen";
  description: string;
  latitude: number;
  longitude: number;
  capacity: string;
}

export interface AnalyzedProject extends EnergyProject {
  opportunityScore: number;
  evidence: string;
  stage: "early" | "planning" | "construction" | "operational";
}

What do these types define?

EnergyProject describes the raw seed data you already have in projects.json.

AnalyzedProject extends it with three AI-generated fields: opportunityScore (1-10), evidence (the AI's reasoning), and stage (where the project sits in its lifecycle). Later components will import these types for type safety across the app.

  • Save app/types.ts.
  • Confirm the file exists by running:
ls app/types.ts

You should see app/types.ts printed in the output.

Create the Route Handler with the scoring schema

A Route Handler in Next.js App Router is a file named route.ts inside an app/api/ folder. The folder path becomes the URL path, so app/api/analyze/route.ts serves requests at /api/analyze.

  • Create the folder structure app/api/analyze/ by right-clicking the app folder, selecting New File, and typing api/analyze/route.ts (VS Code creates the intermediate folders automatically).
  • Add the imports, OpenAI client initialization, and the JSON schema definition:
import { NextResponse } from "next/server";
import OpenAI from "openai";
import projects from "@/app/data/projects.json";

// Initialize the OpenAI client using your API key from .env.local
const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

// Define the schema that Structured Outputs will enforce
const analysisSchema = {
  type: "object" as const,
  properties: {
    opportunityScore: {
      type: "integer" as const,
      description: "Score from 1-10 indicating development opportunity strength",
    },
    evidence: {
      type: "string" as const,
      description: "One paragraph explaining the score based on project signals",
    },
    stage: {
      type: "string" as const,
      enum: ["early", "planning", "construction", "operational"],
      description: "Current development stage of the project",
    },
  },
  required: ["opportunityScore", "evidence", "stage"] as const,
  additionalProperties: false as const,
};

What are Structured Outputs?

Structured Outputs tell the OpenAI API exactly what shape of JSON to return. You define a schema with required fields and types, and the model is constrained to only produce valid responses matching that schema.

The additionalProperties: false and strict: true settings mean no extra fields will sneak in and no required fields will be missing. This eliminates JSON parsing errors entirely.

  • Below the schema, add the first part of the GET handler that loops through projects and calls the OpenAI API:
export async function GET() {
  const analyzedProjects = await Promise.all(
    projects.map(async (project) => {
      // Call GPT-4o-mini for each project with Structured Outputs
      const completion = await openai.chat.completions.create({
        model: "gpt-4o-mini",
        messages: [
          {
            role: "system",
            content:
              "You are an energy infrastructure analyst. Score this project's development opportunity from 1-10 based on its description. Consider: regulatory progress, technology readiness, market demand, and financial signals. Provide evidence for your score and identify the development stage.",
          },
          {
            role: "user",
            content: `Project: ${project.name}\nType: ${project.type}\nCapacity: ${project.capacity}\nDescription: ${project.description}`,
          },
        ],
        response_format: {
          type: "json_schema",
          json_schema: {
            name: "project_analysis",
            strict: true,
            schema: analysisSchema,
          },
        },
      });

What does this API call do?

Promise.all runs all 8 API calls in parallel so the analysis completes faster.

Each call sends a system prompt (defining the AI's role as an energy analyst) and a user message (the project details). The response_format block with json_schema forces the response to match your schema exactly.

  • Directly below the closing }); of the create call, add the parsing and return logic to complete the function:
      // Parse the guaranteed-format JSON response
      const analysis = JSON.parse(
        completion.choices[0].message.content || "{}"
      );

      // Merge original project data with AI analysis
      return {
        ...project,
        ...analysis,
      };
    })
  );

  return NextResponse.json(analyzedProjects);
}

What does the parsing logic do?

The AI response comes as a string inside completion.choices[0].message.content. JSON.parse converts it into a JavaScript object.

The spread operator (...project, ...analysis) merges the original project fields with the new AI-generated fields into one object. Finally, NextResponse.json() sends the full array back as the API response.

  • Save app/api/analyze/route.ts.

Seeing red squiggles in VS Code?

Make sure the opening and closing braces of the GET function are balanced. The function opens with export async function GET() { and closes with a final } at the bottom of the file.

If you see an error about Cannot find module, confirm that app/data/projects.json exists from the previous step.

Help me fix TypeScript errors in my Route Handler.

Test the scoring endpoint

Your Route Handler is complete. Before you run it, think about what you expect to see.

Do you think the API will return plain project data, or something new? What fields should each project now carry?

  • Make sure your dev server is still running (if not, run npm run dev in the terminal).
  • Open your browser and navigate to:
http://localhost:3000/api/analyze

You'll see a JSON array with all 8 projects, each now carrying three new fields: opportunityScore (an integer from 1-10), evidence (a paragraph of reasoning), and stage (one of "early", "planning", "construction", or "operational"). Every response matches the schema exactly.

Why does it take a few seconds?

The handler sends 8 parallel requests to GPT-4o-mini. Even in parallel, each API call takes 1-2 seconds to complete. This is normal for AI-powered endpoints.

Seeing an error instead of JSON?

  • If you see a 500 error, check your terminal for the error message. The most common cause is an invalid API key in .env.local.
  • Make sure you replaced your-api-key-here with your actual OpenAI API key.
  • If the terminal shows a module error, restart the dev server with Ctrl+C then npm run dev to pick up the new .env.local values.

Help me debug my /api/analyze endpoint.

✔️ Awesome, I've got everything!

Great. Double check you've saved both app/types.ts and app/api/analyze/route.ts.

ⓧ I'd like to double check the full code

Scroll up and confirm your app/api/analyze/route.ts contains these sections in order:

  • Three imports at the top (NextResponse, OpenAI, projects).
  • The openai client initialization with process.env.OPENAI_API_KEY.
  • The analysisSchema object with opportunityScore, evidence, and stage properties.
  • The GET() function with Promise.all, the openai.chat.completions.create call, JSON.parse, and NextResponse.json() at the end.

Your app/types.ts should have EnergyProject and AnalyzedProject interfaces matching the schema fields.

Your raw project list now has intelligence behind it. Next up, you'll put those scores on a map so geographic patterns jump out at a glance.

Render the Interactive Map with Scored Markers

You have scored data sitting behind a JSON endpoint. But a list of numbers does not reveal geographic patterns.

Plotting projects on a map with color-coded markers makes clusters, regional trends, and high-opportunity zones instantly visible. This is where the product becomes powerful.

In this step, get ready to:
  • Create an EnergyMap component with colored CircleMarkers for each project.
  • Create a MapWrapper that uses a dynamic import to handle Leaflet's server-side rendering limitation.
  • Build a Dashboard component that fetches scored data and renders the full-screen map.
Create the EnergyMap component

The map needs a component that receives scored projects and renders a colored circle for each one. Green means high opportunity, yellow means medium, and red means low.

  • Create a new folder at app/components/ inside your energy-radar project.
  • Create a new file called EnergyMap.tsx inside app/components/ and add the imports, interface, and color helper:
"use client";

import { MapContainer, TileLayer, CircleMarker, Popup } from "react-leaflet";
import "leaflet/dist/leaflet.css";
import { AnalyzedProject } from "@/app/types";

interface EnergyMapProps {
  projects: AnalyzedProject[];
  onSelectProject: (project: AnalyzedProject) => void;
}

function getMarkerColor(score: number): string {
  if (score >= 7) return "#22c55e";
  if (score >= 4) return "#eab308";
  return "#ef4444";
}

What does this code do?

The "use client" directive marks this as a Client Component because map rendering requires browser APIs.

The getMarkerColor function maps opportunity scores to colors: green (#22c55e) for scores 7-10, yellow (#eab308) for 4-6, and red (#ef4444) for 1-3.

  • Below the getMarkerColor function, add the main EnergyMap component that renders the map and markers:
export default function EnergyMap({ projects, onSelectProject }: EnergyMapProps) {
  return (
    <MapContainer
      center={[39.0, -98.0]}
      zoom={4}
      className="h-full w-full"
      scrollWheelZoom={true}
    >
      <TileLayer
        attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
        url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
      />
      {projects.map((project) => (
        <CircleMarker
          key={project.id}
          center={[project.latitude, project.longitude]}
          radius={12}
          fillColor={getMarkerColor(project.opportunityScore)}
          fillOpacity={0.8}
          color={getMarkerColor(project.opportunityScore)}
          weight={2}
          eventHandlers={{
            click: () => onSelectProject(project),
          }}
        >
          <Popup>
            <strong>{project.name}</strong>
            <br />
            Score: {project.opportunityScore}/10
          </Popup>
        </CircleMarker>
      ))}
    </MapContainer>
  );
}

What does this code do?

MapContainer creates the map viewport, centered on the US at zoom level 4. TileLayer loads free OpenStreetMap tiles as the background.

Each project renders as a CircleMarker positioned at its latitude and longitude. The eventHandlers prop calls onSelectProject when clicked. A Popup shows the project name and score on hover.

  • Save EnergyMap.tsx.

✔️ Awesome, I've got everything!

Great. Make sure you saved the file before moving on.

ⓧ I'd like to double check the full code

Your EnergyMap.tsx should contain two sections in this order:

  • The imports, EnergyMapProps interface, and getMarkerColor helper function (from the first code block above).
  • The EnergyMap component function with MapContainer, TileLayer, and the CircleMarker loop (from the second code block above).

Scroll up and confirm both sections are in your file, in order.

Handle the server-side rendering workaround

Leaflet directly manipulates the DOM when it loads. In Next.js, components render on the server by default, where there is no DOM. Importing Leaflet during server rendering causes a crash.

The fix is a wrapper component that uses a dynamic import with ssr: false to defer the Leaflet import to the browser.

  • Create app/components/MapWrapper.tsx with the following code:
"use client";

import dynamic from "next/dynamic";
import { AnalyzedProject } from "@/app/types";

const EnergyMap = dynamic(() => import("./EnergyMap"), {
  ssr: false,
  loading: () => (
    <div className="h-full w-full flex items-center justify-center bg-gray-800 text-gray-400">
      Loading map...
    </div>
  ),
});

interface MapWrapperProps {
  projects: AnalyzedProject[];
  onSelectProject: (project: AnalyzedProject) => void;
}

export default function MapWrapper({ projects, onSelectProject }: MapWrapperProps) {
  return <EnergyMap projects={projects} onSelectProject={onSelectProject} />;
}

Why a separate wrapper?

The dynamic function from next/dynamic lazily loads a component. Setting ssr: false tells Next.js to skip this import entirely during server-side rendering.

The loading option renders a placeholder while the map JavaScript downloads. This keeps the page responsive during the initial load.

  • Save MapWrapper.tsx.
  • Confirm both new files exist by running:
ls app/components/

You should see EnergyMap.tsx and MapWrapper.tsx listed.

Don't see both files?

Make sure you created the components folder inside app/ (not in the project root). Both files should live at app/components/EnergyMap.tsx and app/components/MapWrapper.tsx.

help me find my component files

✔️ Awesome, I've got everything!

Great. Double check you saved MapWrapper.tsx before continuing.

ⓧ I'd like to double check the full code

"use client";

import dynamic from "next/dynamic";
import { AnalyzedProject } from "@/app/types";

const EnergyMap = dynamic(() => import("./EnergyMap"), {
  ssr: false,
  loading: () => (
    <div className="h-full w-full flex items-center justify-center bg-gray-800 text-gray-400">
      Loading map...
    </div>
  ),
});

interface MapWrapperProps {
  projects: AnalyzedProject[];
  onSelectProject: (project: AnalyzedProject) => void;
}

export default function MapWrapper({ projects, onSelectProject }: MapWrapperProps) {
  return <EnergyMap projects={projects} onSelectProject={onSelectProject} />;
}
Connect the Dashboard to the map

Now you need a Dashboard component that fetches the scored projects from your Route Handler and passes them to the map. The Dashboard also manages which project is selected (for the detail panel you will build in the next step).

  • Create app/components/DetailPanel.tsx as a placeholder. You will build the full panel in the next step:
"use client";

import { AnalyzedProject } from "@/app/types";

interface DetailPanelProps {
  project: AnalyzedProject | null;
  onClose: () => void;
}

export default function DetailPanel({ project, onClose }: DetailPanelProps) {
  if (!project) return null;

  return null;
}
  • Create app/components/Dashboard.tsx and add the imports, state, and data-fetching logic:
"use client";

import { useState, useEffect } from "react";
import MapWrapper from "./MapWrapper";
import DetailPanel from "./DetailPanel";
import { AnalyzedProject } from "@/app/types";

export default function Dashboard() {
  const [projects, setProjects] = useState<AnalyzedProject[]>([]);
  const [selectedProject, setSelectedProject] = useState<AnalyzedProject | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    async function fetchAnalysis() {
      try {
        const response = await fetch("/api/analyze");
        const data = await response.json();
        setProjects(data);
      } catch (error) {
        console.error("Failed to fetch analysis:", error);
      } finally {
        setLoading(false);
      }
    }
    fetchAnalysis();
  }, []);

What does this code do?

The useEffect hook runs once on page load and calls your /api/analyze Route Handler. This triggers GPT-4o-mini to score all 8 projects, then stores the results in projects state.

The loading state starts as true and flips to false once the fetch completes. This lets you show a loading screen while the AI works.

  • Below the useEffect block (still inside the Dashboard function), add the loading screen and main render:
  if (loading) {
    return (
      <div className="h-screen w-screen flex items-center justify-center bg-gray-900 text-white">
        <div className="text-center">
          <p className="text-xl font-semibold">Analyzing energy projects...</p>
          <p className="text-gray-400 mt-2">AI is scoring opportunities</p>
        </div>
      </div>
    );
  }

  return (
    <div className="h-screen w-screen relative">
      <div className="absolute top-4 left-4 z-[1000] bg-gray-900 text-white px-4 py-2 rounded-lg shadow-lg">
        <h1 className="text-lg font-bold">Energy Radar</h1>
        <p className="text-xs text-gray-400">{projects.length} projects analyzed</p>
      </div>
      <MapWrapper projects={projects} onSelectProject={setSelectedProject} />
      <DetailPanel project={selectedProject} onClose={() => setSelectedProject(null)} />
    </div>
  );
}

What does this code do?

While the AI is scoring, the user sees a centered loading message. Once data arrives, the component renders a full-viewport map with a floating title overlay showing how many projects were analyzed.

MapWrapper receives the scored projects and a click handler. DetailPanel is wired up for the next step. Since selectedProject starts as null, it renders nothing for now.

  • Save Dashboard.tsx.

✔️ Awesome, I've got everything!

Great. Confirm you saved the file before continuing.

ⓧ I'd like to double check the full code

Your Dashboard.tsx should contain two sections in this order:

  • The imports, Dashboard function declaration, state hooks, and the useEffect block (from the first code block above).
  • The if (loading) return, the main return with MapWrapper and DetailPanel, and the closing } (from the second code block above).

Scroll up and confirm both sections are in your file, in order.

  • Now update app/page.tsx to render the Dashboard instead of the raw project list. Replace the entire file contents with:
import Dashboard from "./components/Dashboard";

export default function Home() {
  return <Dashboard />;
}

What changed?

The old page.tsx imported the JSON directly and rendered cards. The new version delegates everything to the Dashboard Client Component, which fetches scored data and renders the map.

  • Save page.tsx.

Before you check the browser, think about what you expect to see. The Dashboard fetches from /api/analyze, which calls GPT-4o-mini for each project. Do you think you will see the map instantly, or a loading screen first?

  • Switch to your browser and open http://localhost:3000.

You should first see "Analyzing energy projects..." while GPT-4o-mini scores the data. After a few seconds, a full-screen map appears with 8 colored circle markers spread across the United States. Green, yellow, and red dots represent high, medium, and low opportunity scores.

Seeing a blank page or an error?

  • Check your terminal for errors. If you see a message about a missing module, make sure you installed react-leaflet and leaflet in Step 1.
  • If the map tiles do not load (gray background), confirm you have internet access. The tiles load from OpenStreetMap's servers.
  • If you see "Failed to fetch analysis" in the browser console, check that your dev server is still running and that .env.local contains a valid OpenAI key.

help me debug my Energy Radar map not loading

Your scored data is now alive on a map. You can zoom, pan, and spot geographic clusters at a glance. Next up, you will build the detail panel so clicking a marker reveals the AI's reasoning.

Add the Detail Panel

The colored markers reveal geographic patterns at a glance. You can spot clusters and regional trends. But you still cannot answer the most important question: why did any project score the way it did?

In this step, you'll build a detail panel that slides in from the right when you click any marker. It surfaces the AI's evidence, the project's development stage, and its capacity. This completes the intelligence loop: see a pattern on the map, click to understand it, then act.

In this step, get ready to:
  • Create the DetailPanel component with a colored score badge, stage label, and AI evidence display.
  • Test the full intelligence loop by clicking markers and reading AI reasoning.
Set up the panel helpers and interface

Your Dashboard.tsx already imports and renders a DetailPanel component that does not exist yet. The dev server is showing a module-not-found error because of this missing file.

  • Create a new file at app/components/DetailPanel.tsx and add the following helpers:
"use client";

import { AnalyzedProject } from "@/app/types";

interface DetailPanelProps {
  project: AnalyzedProject | null;
  onClose: () => void;
}

// Map opportunity score to a Tailwind background color class
function getScoreColor(score: number): string {
  if (score >= 7) return "bg-green-500";
  if (score >= 4) return "bg-yellow-500";
  return "bg-red-500";
}

// Convert stage key to a human-readable label
function getStageLabel(stage: string): string {
  const labels: Record<string, string> = {
    early: "Early Stage",
    planning: "Planning",
    construction: "Construction",
    operational: "Operational",
  };
  return labels[stage] || stage;
}

What does this code do?

The interface defines two props: the selected project (or null when nothing is selected) and an onClose callback to dismiss the panel.

The two helper functions translate raw data into display values. getScoreColor returns a Tailwind class matching the same green/yellow/red logic you used for markers. getStageLabel converts API stage values like early into readable labels like "Early Stage".

  • Confirm DetailPanel.tsx appears in the Explorer sidebar under app/components/.
Build the panel component

The component renders a fixed right-side panel with the project's header, score badge, and AI evidence. When no project is selected, it returns nothing.

  • Below the getStageLabel function, add the component with its header and score badge section:
export default function DetailPanel({ project, onClose }: DetailPanelProps) {
  if (!project) return null;

  return (
    <div className="fixed top-0 right-0 h-full w-96 bg-gray-900 text-white shadow-2xl z-[1000] overflow-y-auto transition-transform duration-300">
      <div className="p-6">
        <div className="flex justify-between items-start mb-4">
          <h2 className="text-xl font-bold">{project.name}</h2>
          <button
            onClick={onClose}
            className="text-gray-400 hover:text-white text-2xl leading-none"
          >
            x
          </button>
        </div>

        <div className="flex items-center gap-3 mb-4">
          <span
            className={`${getScoreColor(project.opportunityScore)} text-white text-lg font-bold w-10 h-10 rounded-full flex items-center justify-center`}
          >
            {project.opportunityScore}
          </span>
          <div>
            <p className="text-sm text-gray-400">Opportunity Score</p>
            <p className="text-sm text-gray-300">{getStageLabel(project.stage)}</p>
          </div>
        </div>
  • Immediately below that code (still inside the same component), add the type badge, AI evidence, and description sections, plus the closing tags:
        <div className="mb-4">
          <span className="inline-block px-2 py-1 text-xs font-medium bg-gray-700 rounded uppercase">
            {project.type}
          </span>
          <span className="ml-2 text-sm text-gray-400">{project.capacity}</span>
        </div>

        <div className="mb-4">
          <h3 className="text-sm font-semibold text-gray-400 mb-1">AI Evidence</h3>
          <p className="text-sm text-gray-200 leading-relaxed">{project.evidence}</p>
        </div>

        <div>
          <h3 className="text-sm font-semibold text-gray-400 mb-1">Description</h3>
          <p className="text-sm text-gray-300 leading-relaxed">{project.description}</p>
        </div>
      </div>
    </div>
  );
}

What does this component do?

When project is null, the component renders nothing. The panel only appears when a marker is clicked.

The panel uses fixed positioning with z-[1000] to float above the map. The score badge uses the same green/yellow/red color logic as the map markers, creating a visual link between the dot on the map and the detail view.

  • Save DetailPanel.tsx.
  • Switch back to your browser at http://localhost:3000. The module-not-found error should be gone and the map should load again.
  • Click any colored marker on the map.

A dark panel slides in from the right showing the project name, a colored score badge, the development stage, an energy type badge, and the AI's evidence paragraph.

Panel not appearing when you click?

  • Check the browser console for TypeScript errors. A missing closing tag or bracket in your JSX will prevent the component from rendering.
  • Verify the file is saved as DetailPanel.tsx (not detailpanel.tsx). The import in Dashboard.tsx is case-sensitive.
  • Make sure your component has export default on the function. Without it, the Dashboard import resolves to nothing.

Why isn't my DetailPanel showing when I click a map marker?

Test the full intelligence loop

The complete loop is: see a pattern on the map, click to understand it, then decide what to do. Try it now.

  • Click a green marker (high-scoring project). Read the AI's evidence to understand why it scored high.
  • Click a red marker (low-scoring project). The panel updates immediately with different evidence.
  • Click the x button in the panel's top-right corner. The panel closes.

Before you click the next marker, predict: will the panel update in place, or will it close and reopen?

  • Click a different marker while the panel is still open.

The panel updates in place. Because React re-renders the component when the selectedProject state changes, the panel smoothly swaps its content without closing and reopening.

How does the click wiring work?

In the previous step, your Dashboard component created a selectedProject state variable and passed setSelectedProject down to MapWrapper as onSelectProject. Each CircleMarker calls that function on click, which updates the state and triggers a re-render of DetailPanel with the new project data.

✔️ Awesome, I've got everything!

Great. Double check you have saved DetailPanel.tsx and that clicking markers opens the panel with AI evidence.

ⓧ I'd like to double check the full code

Here is the complete DetailPanel.tsx file. Scroll through and confirm each section is present in order: imports, interface, two helper functions, and the component with its five display sections.

Section 1: Imports and helpers (top of file):

"use client";

import { AnalyzedProject } from "@/app/types";

interface DetailPanelProps {
  project: AnalyzedProject | null;
  onClose: () => void;
}

function getScoreColor(score: number): string {
  if (score >= 7) return "bg-green-500";
  if (score >= 4) return "bg-yellow-500";
  return "bg-red-500";
}

function getStageLabel(stage: string): string {
  const labels: Record<string, string> = {
    early: "Early Stage",
    planning: "Planning",
    construction: "Construction",
    operational: "Operational",
  };
  return labels[stage] || stage;
}

Section 2: Component header and score badge:

export default function DetailPanel({ project, onClose }: DetailPanelProps) {
  if (!project) return null;

  return (
    <div className="fixed top-0 right-0 h-full w-96 bg-gray-900 text-white shadow-2xl z-[1000] overflow-y-auto transition-transform duration-300">
      <div className="p-6">
        <div className="flex justify-between items-start mb-4">
          <h2 className="text-xl font-bold">{project.name}</h2>
          <button
            onClick={onClose}
            className="text-gray-400 hover:text-white text-2xl leading-none"
          >
            x
          </button>
        </div>

        <div className="flex items-center gap-3 mb-4">
          <span
            className={`${getScoreColor(project.opportunityScore)} text-white text-lg font-bold w-10 h-10 rounded-full flex items-center justify-center`}
          >
            {project.opportunityScore}
          </span>
          <div>
            <p className="text-sm text-gray-400">Opportunity Score</p>
            <p className="text-sm text-gray-300">{getStageLabel(project.stage)}</p>
          </div>
        </div>

Section 3: Type badge, evidence, description, and closing tags:

        <div className="mb-4">
          <span className="inline-block px-2 py-1 text-xs font-medium bg-gray-700 rounded uppercase">
            {project.type}
          </span>
          <span className="ml-2 text-sm text-gray-400">{project.capacity}</span>
        </div>

        <div className="mb-4">
          <h3 className="text-sm font-semibold text-gray-400 mb-1">AI Evidence</h3>
          <p className="text-sm text-gray-200 leading-relaxed">{project.evidence}</p>
        </div>

        <div>
          <h3 className="text-sm font-semibold text-gray-400 mb-1">Description</h3>
          <p className="text-sm text-gray-300 leading-relaxed">{project.description}</p>
        </div>
      </div>
    </div>
  );
}

The intelligence loop is complete. You have a working AI-powered platform where you can see geographic patterns on the map, click any project to understand the AI's reasoning, and use that insight to make decisions. Next up: a bonus challenge to add filtering controls that narrow the map by energy type and score threshold.

Secret mission

Add Filtering Controls

Your Energy Radar shows every project at once. A real intelligence platform lets analysts narrow their view by energy type and minimum opportunity score. Can you add filtering controls that update the map in real time?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • The energy-radar project folder on your Desktop.
  • OpenAI API credit used (approximately $0.01 from your $50 credit, already consumed).
  • The .env.local file containing your OpenAI API key (inside the project folder).

Keep everything running

No action needed. Choose this if you are still actively building on Energy Radar or plan to extend it further.

  • Your project lives in the energy-radar folder on your Desktop. There are no cloud resources and no recurring costs.
  • If you push this project to a public repository, make sure .env.local is listed in your .gitignore file so your API key stays private. Next.js adds this by default.

Pause - I'll come back to this later

Stop the dev server to free up your terminal. Your files stay on disk ready for next time.

  • Press Ctrl+C in the terminal where npm run dev is running to stop the development server.
  • When you return, open the energy-radar folder in VS Code and run npm run dev to start again.

Delete - I don't want to use this again

Remove all project files from your machine. This is permanent.

  • Press Ctrl+C in the terminal to stop the dev server if it is still running.
  • Delete the entire project folder by running this command in your terminal:
rm -rf ~/Desktop/energy-radar

On Windows, run this instead:

Remove-Item -Recurse -Force ~\Desktop\energy-radar
  • Your OpenAI API key remains active in your OpenAI account. The small amount of credit used (approximately $0.01) is already consumed and cannot be recovered.

Nice Work!

Nice Work!

Nice work! You just built Energy Radar, a full-stack AI-powered intelligence platform that transforms raw energy project data into scored, mapped, and explorable insights.

Here's what you accomplished:

  • Built an AI scoring pipeline using OpenAI Structured Outputs that guarantees consistent JSON from GPT-4o-mini. No parsing errors, no hallucinated fields.
  • Rendered an interactive Leaflet map with color-coded markers using dynamic imports to handle server-side rendering in the Next.js App Router.
  • Created a Route Handler that keeps your API key secure on the server and a click-to-inspect detail panel that reveals the AI's reasoning for each project score.
  • Secret Mission: Added filtering controls for energy type and minimum score threshold, turning the demo into a production-ready analyst tool.

Ready to quiz yourself?