22 KiB
Energy & AI: The Datacenter Power Crunch
An interactive dashboard visualizing how AI datacenter buildout is driving regional electricity demand and energy prices across the United States.
Why This Matters
The AI boom isn't just a software story — it's an energy story. Training a single frontier model can consume as much electricity as a small town uses in a year. And we're not talking about one model: every major tech company is racing to build out GPU clusters at unprecedented scale.
This is creating a tectonic shift in US energy markets:
- Dominion Energy (Virginia/PJM) has seen datacenter load applications surge 10x since 2020. Northern Virginia alone hosts ~70% of the world's internet traffic and is adding gigawatts of new AI load.
- ERCOT (Texas) is fielding datacenter interconnection requests totaling more than the entire current grid capacity of some US states.
- Natural gas prices are being pushed up because gas-fired power plants are the marginal generator in most US regions — when demand spikes, gas sets the price.
- Grid reliability is under threat: regions that were comfortably supplied five years ago are now facing capacity shortfalls, triggering emergency demand response events.
The people making billion-dollar decisions about this — energy investors, utility planners, datacenter operators, commodity traders — need real-time visibility into where demand is concentrating, how prices are responding, and which regions are approaching their limits. That's what this dashboard provides.
Business Case
The problem: Energy market data is scattered across dozens of sources (EIA, ISOs, FRED, commodity exchanges) with inconsistent formats, no geospatial context, and no connection to the AI infrastructure buildout driving the changes. Analysts spend hours stitching together spreadsheets to answer basic questions like "how have electricity prices changed in regions with heavy datacenter buildout?"
The solution: A single, real-time dashboard that overlays datacenter locations on energy market data, making the AI-energy nexus immediately visible and explorable.
Target audience: Energy investors evaluating utility stocks and commodity positions. Utility analysts planning generation and transmission investments. Datacenter site selectors choosing where to build next. Business strategists assessing the infrastructure costs underlying AI.
Monetization angle: Freemium model. Free tier provides the dashboard with real-time data. Premium tier adds predictive analytics (price forecasting, capacity constraint alerts), custom region comparisons, CSV/API data export, and email alerting for price spike events. Enterprise tier provides embeddable widgets for trading desks and analyst reports.
Tech Stack
| Layer | Technology | Version | Why |
|---|---|---|---|
| Framework | Next.js (App Router) | 16 | Turbopack default, "use cache" directive, React 19.2 — the most capable React framework available |
| Styling | Tailwind CSS | 4 | CSS-first config, zero-runtime, pairs perfectly with shadcn/ui |
| Components | shadcn/ui | latest | Copy-paste components, not a dependency — full control, great defaults, built-in chart components (Recharts wrappers) |
| Maps | @vis.gl/react-google-maps | latest | Google's own recommended React library for Maps. Declarative, hooks-based, AdvancedMarker support |
| Database | PostgreSQL + PostGIS | 18 + 3.5 | PostGIS is the gold standard for geospatial queries — ST_DWithin, ST_Distance, polygon containment, all in SQL |
| ORM | Prisma (with TypedSQL) | 7.x | TypedSQL lets us write .sql files for PostGIS queries and get fully typed TypeScript wrappers. Best of both worlds: Prisma for CRUD, raw SQL for geo |
| Runtime / PM | Bun | latest | Fastest JS runtime, built-in TS support, great package manager |
| Charts | Recharts (via shadcn/ui) | latest | shadcn/ui's chart components wrap Recharts with consistent theming — no extra config |
| Serialization | superjson | latest | Preserves Date, BigInt, Map, Set across server→client boundary. Without it, every timestamp silently becomes a string while TypeScript still calls it Date |
| Containerization | Docker Compose | - | One command to spin up PostGIS. Reproducible dev environment |
Type Safety Strategy (E2E)
External APIs like EIA and FRED return untyped JSON. A single bad response shape can cascade into runtime crashes or — worse — silently wrong data on a dashboard people might make investment decisions from. Every data boundary is typed and validated:
External APIs → Zod schemas → Server (validated, typed)
Database → Prisma generated types → Server
PostGIS queries → TypedSQL (.sql files) → Typed wrappers
Server → Client: superjson serialize/deserialize (preserves Date, BigInt, etc.)
→ Next.js Server Actions + Server Components (typed props)
Forms → Zod + react-hook-form (validated inputs)
No any. No unvalidated API responses. If the EIA changes their response format, we get a Zod parse error at ingestion time, not a broken chart at render time.
Why superjson?
React Server Components and Server Actions serialize data using JSON.stringify under the hood. This silently destroys rich types:
Date→string(but TypeScript still saysDate)Map→{}Set→{}BigInt→ throws an errorundefined→ omitted
For a dashboard where almost every data point has a TIMESTAMPTZ, this is catastrophic. You think you have a Date you can call .getTime() on, but you actually have "2026-01-15T14:30:00.000Z" and your code silently breaks or produces wrong calculations.
superjson wraps serialization to preserve these types. Every Server Action and every Server Component that passes temporal or complex data to client components must use superjson at the boundary. Create a shared utility (src/lib/superjson.ts) that standardizes this across the codebase.
Data Sources
We use two categories of data: real-time feeds from government APIs (free, reliable, well-documented) and curated seed data for the geospatial layer.
Real-Time (API-driven)
| Source | Data | Granularity | Update Frequency | Why This Source |
|---|---|---|---|---|
| EIA API | Regional electricity prices, demand, generation mix | Hourly, by balancing authority | Hourly | The definitive US energy data source. Free, 9k req/hr, decades of history. Covers all ISO/RTO regions. |
| EIA API | Natural gas (Henry Hub), WTI crude, coal spot prices | Daily/weekly | Daily | Gas prices directly drive electricity prices (gas is the marginal fuel). Oil/coal provide macro context. |
| FRED API | Historical commodity price time series | Daily | Daily | Clean, reliable time series going back to the 1940s. Perfect for long-run trend analysis. |
Static / Seed Data
| Source | Data | Why |
|---|---|---|
| DataCenterMap / manual curation | Datacenter locations (lat/lng, operator, capacity MW) | The core geospatial layer. PostGIS Point geometries enable spatial queries (nearby DCs, DCs in region, clustering). |
| EIA / ISO boundaries | Grid region polygons (PJM, ERCOT, CAISO, etc.) | PostGIS MultiPolygon geometries enable the price heatmap overlay and spatial joins between DCs and regions. |
| AI milestones | Timeline of major AI announcements | Chart annotations that tell the story — "here's when ChatGPT launched, here's when prices started climbing." Turns data into narrative. |
Database Schema (PostgreSQL + PostGIS)
┌─────────────────────────┐ ┌──────────────────────────┐
│ datacenters │ │ grid_regions │
├─────────────────────────┤ ├──────────────────────────┤
│ id UUID PK │ │ id UUID PK │
│ name TEXT │ │ name TEXT │
│ operator TEXT │ │ code TEXT │ (e.g. "PJM", "ERCOT")
│ location GEOGRAPHY │◄───►│ boundary GEOGRAPHY │ (MultiPolygon)
│ (Point, 4326) │ │ iso TEXT │
│ capacity_mw FLOAT │ │ created_at TIMESTAMPTZ│
│ status TEXT │ └──────────────────────────┘
│ year_opened INT │
│ region_id UUID FK │──────┘
│ created_at TIMESTAMPTZ│
└─────────────────────────┘
┌─────────────────────────┐ ┌──────────────────────────┐
│ electricity_prices │ │ commodity_prices │
├─────────────────────────┤ ├──────────────────────────┤
│ id UUID PK │ │ id UUID PK │
│ region_id UUID FK │ │ commodity TEXT │ (natural_gas, wti_crude, coal)
│ price_mwh FLOAT │ │ price FLOAT │
│ demand_mw FLOAT │ │ unit TEXT │
│ timestamp TIMESTAMPTZ│ │ timestamp TIMESTAMPTZ│
│ source TEXT │ │ source TEXT │
└─────────────────────────┘ └──────────────────────────┘
┌─────────────────────────┐
│ generation_mix │
├─────────────────────────┤
│ id UUID PK │
│ region_id UUID FK │
│ fuel_type TEXT │ (gas, nuclear, wind, solar, coal, hydro)
│ generation_mw FLOAT │
│ timestamp TIMESTAMPTZ│
└─────────────────────────┘
TypedSQL Queries (prisma/sql/)
Examples of PostGIS queries that get typed wrappers:
findDatacentersInRegion.sql
-- @param {String} $1:regionCode
SELECT
d.id, d.name, d.operator, d.capacity_mw, d.status, d.year_opened,
ST_AsGeoJSON(d.location)::TEXT as location_geojson
FROM datacenters d
JOIN grid_regions r ON d.region_id = r.id
WHERE r.code = $1
ORDER BY d.capacity_mw DESC
findNearbyDatacenters.sql
-- @param {Float} $1:lat
-- @param {Float} $2:lng
-- @param {Float} $3:radiusKm
SELECT
d.id, d.name, d.operator, d.capacity_mw,
ST_AsGeoJSON(d.location)::TEXT as location_geojson,
ST_Distance(d.location, ST_MakePoint($2, $1)::geography) / 1000 as distance_km
FROM datacenters d
WHERE ST_DWithin(d.location, ST_MakePoint($2, $1)::geography, $3 * 1000)
ORDER BY distance_km
getRegionPriceHeatmap.sql
SELECT
r.code, r.name,
ST_AsGeoJSON(r.boundary)::TEXT as boundary_geojson,
AVG(ep.price_mwh) as avg_price,
MAX(ep.price_mwh) as max_price,
AVG(ep.demand_mw) as avg_demand,
COUNT(DISTINCT d.id)::INT as datacenter_count,
COALESCE(SUM(d.capacity_mw), 0) as total_dc_capacity_mw
FROM grid_regions r
LEFT JOIN electricity_prices ep ON ep.region_id = r.id
AND ep.timestamp > NOW() - INTERVAL '24 hours'
LEFT JOIN datacenters d ON d.region_id = r.id
GROUP BY r.id, r.code, r.name, r.boundary
Real-Time Candy
These features make the dashboard feel alive — like a trading floor terminal, not a static report.
Live Price Ticker Tape
A scrolling horizontal banner across the top of every page showing current regional electricity prices and commodity spot prices — styled like a financial news ticker. Green/red coloring for price direction. Always visible, always updating.
Animated Number Transitions
Hero metrics (avg electricity price, gas spot, total DC capacity) use smooth count-up/count-down animations when data updates. Numbers don't just appear — they roll to the new value. Uses framer-motion animate with spring physics.
Pulsing Map Markers
Datacenter markers on the Google Map emit a soft radial pulse animation when their region's electricity price exceeds its 30-day average. The faster the pulse, the bigger the price deviation. A calm map means stable prices; a map full of pulsing dots means something interesting is happening.
GPU Cost Calculator (Live)
A sticky widget: "Running 1,000 H100 GPUs right now costs $X,XXX/hr in Virginia vs $Y,YYY/hr in Texas." Updates with live regional prices. Users can adjust GPU count with a slider. Makes the abstract price data immediately tangible — this is what it actually costs to train AI right now.
Grid Stress Gauges
Radial gauge components per region showing current demand / peak capacity as a percentage. Styled like a speedometer — green zone, yellow zone, red zone. When a region creeps past 85% capacity utilization, the gauge glows red. Immediate visual signal for grid stress.
Price Spike Toasts
sonner toast notifications that pop up when any region's price crosses a configurable threshold (e.g., >$100/MWh) or hits a new 30-day high. Persistent in the bottom-right corner. Gives that "breaking news" feeling.
Auto-Refresh with Countdown
A subtle countdown timer in the nav: "Next refresh in 47s". Data auto-refreshes on a configurable interval (default 60s). Uses Next.js router.refresh() to re-run server components without a full page reload. The countdown itself is a client component with requestAnimationFrame for smooth ticking.
Ambient Region Glow
On the map, grid region polygons don't just use static fill colors — they have a subtle CSS animation that "breathes" (opacity oscillation). Higher-priced regions breathe faster and brighter. The map looks alive at a glance.
Pages & Views
1. Dashboard Home (/)
- Hero metrics: Live national avg electricity price, natural gas spot, total DC capacity
- Price change sparklines: 24h/7d/30d trends for key indicators
- Recent alerts: Notable price spikes or demand records
- Quick map preview: Thumbnail of the full map with DC hotspots
2. Interactive Map (/map)
- Google Maps with custom styling (dark theme)
- Datacenter markers: Clustered markers sized by capacity (MW), colored by operator
- Regional overlays: Grid region polygons colored by current electricity price (heatmap)
- Click interactions: Click a region to see detail panel (prices, demand, generation mix, DC list)
- Click a datacenter: See operator, capacity, year opened, regional context
- Filters: By operator, capacity range, region, time period
3. Price Trends (/trends)
- Multi-line charts: Regional electricity prices over time (selectable regions)
- Commodity overlay: Natural gas / crude oil prices on secondary axis
- AI milestone annotations: Vertical markers for ChatGPT launch, major cluster announcements
- Correlation view: Scatter plot of DC capacity vs regional price
- Time range selector: 1M, 3M, 6M, 1Y, ALL
4. Demand Analysis (/demand)
- Regional demand growth: Bar/line charts showing demand trends by ISO region
- Peak demand tracking: Historical peak demand records
- Forecast overlay: EIA demand forecasts where available
- DC impact estimation: Estimated datacenter load as percentage of regional demand
5. Generation Mix (/generation)
- Stacked area charts: Generation by fuel type per region over time
- Renewable vs fossil split: How DC-heavy regions compare
- Carbon intensity proxy: Generation mix as indicator of grid cleanliness
Project Structure
bonus4/
├── docker-compose.yml
├── .env # API keys (EIA, FRED, Google Maps)
├── .prettierrc.js # (existing)
├── tsconfig.json # (existing)
├── eslint.config.js # (existing)
├── next.config.ts
├── package.json
├── prisma/
│ ├── schema.prisma
│ ├── migrations/
│ ├── seed.ts # Datacenter locations, region boundaries, AI milestones
│ └── sql/ # TypedSQL queries
│ ├── findDatacentersInRegion.sql
│ ├── findNearbyDatacenters.sql
│ ├── getRegionPriceHeatmap.sql
│ ├── getLatestPrices.sql
│ ├── getPriceTrends.sql
│ ├── getDemandByRegion.sql
│ └── getGenerationMix.sql
├── src/
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── page.tsx # Dashboard home
│ │ ├── map/
│ │ │ └── page.tsx
│ │ ├── trends/
│ │ │ └── page.tsx
│ │ ├── demand/
│ │ │ └── page.tsx
│ │ ├── generation/
│ │ │ └── page.tsx
│ │ └── api/
│ │ └── ingest/ # Data ingestion endpoints (cron-triggered)
│ │ ├── electricity/route.ts
│ │ ├── commodities/route.ts
│ │ └── generation/route.ts
│ ├── components/
│ │ ├── ui/ # shadcn/ui components
│ │ ├── map/
│ │ │ ├── energy-map.tsx # Main Google Maps component
│ │ │ ├── datacenter-marker.tsx
│ │ │ ├── region-overlay.tsx
│ │ │ └── map-controls.tsx
│ │ ├── charts/
│ │ │ ├── price-chart.tsx
│ │ │ ├── demand-chart.tsx
│ │ │ ├── generation-chart.tsx
│ │ │ └── sparkline.tsx
│ │ ├── dashboard/
│ │ │ ├── metric-card.tsx
│ │ │ └── alerts-feed.tsx
│ │ └── layout/
│ │ ├── nav.tsx
│ │ └── footer.tsx
│ ├── lib/
│ │ ├── db.ts # Prisma client singleton
│ │ ├── api/
│ │ │ ├── eia.ts # EIA API client + Zod schemas
│ │ │ └── fred.ts # FRED API client + Zod schemas
│ │ ├── schemas/ # Shared Zod schemas
│ │ │ ├── electricity.ts
│ │ │ ├── commodities.ts
│ │ │ └── geo.ts
│ │ └── utils.ts
│ ├── actions/ # Server Actions (typed server→client boundary)
│ │ ├── prices.ts
│ │ ├── datacenters.ts
│ │ ├── demand.ts
│ │ └── generation.ts
│ └── types/
│ └── index.ts # Shared type definitions
├── scripts/
│ └── seed-datacenters.ts # One-time seed script for DC location data
├── Assignment.md
├── CLAUDE.md
└── SPEC.md
Docker Compose
services:
db:
image: postgis/postgis:18-3.5
ports:
- "5433:5432"
environment:
POSTGRES_DB: energy_dashboard
POSTGRES_USER: energy
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Port 5433 externally (5432 is occupied by another project).
Data Ingestion Strategy
The dashboard needs to feel live without hammering external APIs. The strategy is: fetch once, cache in Postgres, serve from cache, refresh on a schedule.
- On-demand + cache: Server actions check Postgres first. If the latest cached data is within the TTL window (e.g., 30 min for electricity, 6 hours for commodities), serve from cache. Otherwise, fetch fresh from EIA/FRED, validate with Zod, upsert into Postgres, and return.
- API route ingestion:
/api/ingest/*routes provide a manual trigger and a cron-compatible endpoint for bulk data pulls. Useful for backfilling historical data and for production scheduled ingestion. - Seed data: Datacenter locations and grid region boundaries are relatively static. Loaded via
prisma db seedfrom curated JSON/GeoJSON files. - Rate limit awareness: EIA allows ~9k req/hr (generous), FRED allows 120/min. With caching, we'll typically make <100 EIA requests/hour even under heavy use. The real bottleneck is EIA's 5,000-row-per-query limit — pagination handled in the API client.
Implementation Phases
Phase 1: Foundation
- Scaffold Next.js 16 project
- Copy into bonus4 dir, integrate existing prettier/eslint/tsconfig
- Install and configure shadcn/ui + Tailwind 4
- Docker Compose for PostgreSQL 18 + PostGIS
- Prisma schema + initial migration (with PostGIS extension)
- Seed datacenter locations + grid region boundaries
Phase 2: Data Layer
- EIA API client with Zod validation
- FRED API client with Zod validation
- TypedSQL queries for all geospatial operations
- Server actions for data access (typed server→client boundary)
- Ingestion API routes
Phase 3: Dashboard UI
- App layout (nav, sidebar, footer)
- Dashboard home with metric cards + sparklines
- Google Maps integration with datacenter markers
- Region polygon overlays with price heatmap coloring
- Click interactions (region detail panel, DC detail panel)
Phase 4: Charts & Analysis
- Price trend charts (Recharts via shadcn/ui)
- Demand analysis views
- Generation mix charts
- AI milestone annotations
- Correlation views
Phase 5: Polish
- Responsive design
- Loading states + error boundaries
- Disclaimers (educational/informational purposes)
- One-page summary document
- README with installation docs