Intermediate

Building Your AI Dashboard

What this gives you: One screen that shows your entire business. Revenue from Stripe, leads from your CRM, content performance, pipeline status - all pulling live data in real-time. No more switching between 10 tabs to understand how things are going.

Paste this guide into Claude Code and it will walk you through every step interactively.

Why You Need a Command Centre

An AI assistant that lives only in a terminal is powerful but fragile. Every piece of business context - revenue, pipeline, content queue, client status - lives scattered across 8 different tabs. When you open Claude Code to do something, half the time you spend is gathering that context manually before you can even start.

A dashboard solves this. One URL. Every metric that matters, live, server-rendered, on a single screen. Revenue from Stripe. Pipeline from GHL. Content queue from Trello. Upcoming calls from Calendar. Open invoices from your accounting tool. When you open Claude Code with your dashboard alongside it, you have full business context in 10 seconds. No tab-switching, no manual data fetching, no copy-paste.

The second function is the pricing engine. Most service businesses price inconsistently - different quotes for similar work, forgotten line items, no record of what was charged when. A dashboard-embedded pricing tool fixes this: a structured form with your service components, complexity multipliers, and bundle logic. Same input, same output, every time. When a prospect asks "what would this cost?" you have an answer in 30 seconds, not 30 minutes.

The third function is Claude-powered quote generation. Paste a meeting transcript, click generate - Claude reads the transcript, identifies what services were discussed, maps them to your pricing components, and produces a draft proposal. Your dashboard becomes the interface where rough calls become structured quotes.

The Stack

The stack is deliberately minimal. Every choice is driven by two constraints: fast to build, zero infrastructure to manage.

TechnologyRole in the Stack
Next.jsApp Router, TypeScript. Server components handle all data fetching and keep API keys off the browser. API routes give you server-side endpoints for webhooks and data aggregation.
Tailwind CSSUtility classes, dark theme by default. No CSS files to maintain. The design system lives in the component classes.
VercelDeploy by pushing to git. Zero config. Automatic HTTPS. Environment variables stored in the Vercel dashboard, never in code.
No databaseAll data is fetched live from source APIs (Stripe, GHL, Beehiiv) at request time via server components. No sync jobs, no stale data, no database to maintain.

App Directory Structure

my-dashboard/ ├── app/ │ ├── page.tsx # login / password gate │ ├── layout.tsx # root layout, fonts, metadata │ ├── dashboard/ │ │ ├── page.tsx # main dashboard (server component) │ │ └── _components/ │ │ ├── RevenueCard.tsx │ │ ├── PipelineCard.tsx │ │ ├── ContentQueue.tsx │ │ └── PricingTool.tsx # client component (interactive) │ └── api/ │ ├── live-data/ │ │ └── route.ts # Stripe + GHL aggregated endpoint │ ├── generate-quote/ │ │ └── route.ts # Claude-powered quote from transcript │ └── auth/ │ └── route.ts # password check, sets cookie ├── lib/ │ ├── stripe.ts # Stripe SDK wrapper │ ├── ghl.ts # GHL API helpers │ └── pricing.ts # pricing engine logic ├── .env.local # secrets (never committed) └── vercel.json # optional: headers, rewrites

Password Gate Implementation

The dashboard contains live revenue and pipeline data - it needs to be behind auth. A full OAuth implementation is overkill for a private internal tool. A simple password check with a cookie is the right call: takes 30 minutes to implement, impossible to accidentally forget to apply to new pages.

// middleware.ts - runs on every request import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' export function middleware(request: NextRequest) { // Skip auth check for the login page and auth API const { pathname } = request.nextUrl if (pathname === '/' || pathname.startsWith('/api/auth')) { return NextResponse.next() } // Check for auth cookie const auth = request.cookies.get('dashboard-auth') if (!auth || auth.value !== process.env.AUTH_COOKIE_VALUE) { return NextResponse.redirect(new URL('/', request.url)) } return NextResponse.next() } export const config = { matcher: ['/dashboard/:path*', '/api/live-data'] }
// app/api/auth/route.ts - password check import { NextRequest, NextResponse } from 'next/server' export async function POST(req: NextRequest) { const { password } = await req.json() if (password !== process.env.DASHBOARD_PASSWORD) { return NextResponse.json( { error: 'Invalid password' }, { status: 401 } ) } const response = NextResponse.json({ ok: true }) response.cookies.set('dashboard-auth', process.env.AUTH_COOKIE_VALUE!, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', maxAge: 60 * 60 * 24 * 7 // 7 days }) return response }
Tip: Generate a random AUTH_COOKIE_VALUE - something like a 32-character random string. This is what gets stored in the cookie and checked on every request. Store both DASHBOARD_PASSWORD and AUTH_COOKIE_VALUE in Vercel's environment variables, not in code.

Server-Side API Routes: Keys Never Hit the Browser

Every API call that needs a secret key goes through a Next.js API route or server component. The browser never sees a secret. This is not just good security hygiene - it is the architecture that makes the dashboard maintainable. API keys rotate, services change, and when all your external calls are in one place (lib/stripe.ts, lib/ghl.ts), you update one file instead of hunting through components.

// lib/stripe.ts - server-side only import Stripe from 'stripe' const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-11-20.acacia' }) export async function getMonthRevenue() { const start = new Date() start.setDate(1) start.setHours(0, 0, 0, 0) const charges = await stripe.charges.list({ created: { gte: Math.floor(start.getTime() / 1000) }, limit: 100 }) const total = charges.data .filter(c => c.status === 'succeeded') .reduce((sum, c) => sum + c.amount, 0) return { mtd: total / 100, count: charges.data.length, currency: 'gbp' } } export async function getMRR() { const subscriptions = await stripe.subscriptions.list({ status: 'active', limit: 100 }) const mrr = subscriptions.data.reduce((sum, sub) => { const monthly = sub.items.data.reduce((s, item) => { const price = item.price if (price.recurring?.interval === 'month') { return s + (price.unit_amount ?? 0) * item.quantity! } if (price.recurring?.interval === 'year') { return s + ((price.unit_amount ?? 0) * item.quantity!) / 12 } return s }, 0) return sum + monthly }, 0) return { mrr: mrr / 100, activeSubscriptions: subscriptions.data.length } }

Pulling Live Data from GHL and Beehiiv

// lib/ghl.ts - GoHighLevel pipeline data const GHL_BASE = 'https://services.leadconnectorhq.com' const headers = { 'Authorization': `Bearer ${process.env.GHL_API_KEY}`, 'Version': '2021-07-28', 'Content-Type': 'application/json' } export async function getPipelineStats() { const res = await fetch( `${GHL_BASE}/opportunities/search?location_id=${process.env.GHL_LOCATION_ID}&limit=100`, { headers, next: { revalidate: 300 } } // cache 5 mins ) const data = await res.json() const opps = data.opportunities ?? [] // Group by stage const byStage = opps.reduce((acc: Record<string, number>, opp: any) => { const stage = opp.status ?? 'unknown' acc[stage] = (acc[stage] ?? 0) + 1 return acc }, {}) const totalValue = opps.reduce((sum: number, opp: any) => sum + (opp.monetaryValue ?? 0), 0) return { byStage, totalValue, count: opps.length } }
// Beehiiv newsletter stats export async function getNewsletterStats() { const res = await fetch( `https://api.beehiiv.com/v2/publications/${process.env.BEEHIIV_PUB_ID}/subscriptions?status=active`, { headers: { 'Authorization': `Bearer ${process.env.BEEHIIV_API_KEY}` }, next: { revalidate: 3600 } // cache 1 hour } ) const data = await res.json() return { subscribers: data.total_results ?? 0 } }

The Pricing Engine

The pricing engine is a structured set of components with defined costs, multipliers, and bundle logic. It lives in lib/pricing.ts and is used both by the interactive pricing tool in the dashboard and by the Claude-powered quote generation endpoint.

Here's what the pricing engine covers:

ServiceSetup FeeMonthly
Voice AI (Inbound)£800£400/mo
Voice AI (Outbound)£1,000£500/mo
Lead Nurture£600£300/mo
Email Agent£500£250/mo
AI Chatbot£400£200/mo
// lib/pricing.ts export const SERVICES = { inbound_voice: { label: 'AI Inbound Receptionist', setup: 800, monthly: 600, description: '24/7 call handling, appointment booking, FAQ responses' }, outbound_voice: { label: 'AI Outbound Voice Agent', setup: 1200, monthly: 800, description: 'Lead reactivation, follow-up calls, appointment confirmation' }, lead_nurturing: { label: 'AI Lead Nurture Sequence', setup: 600, monthly: 400, description: 'SMS/email sequences, CRM automation, lead scoring' }, email_agent: { label: 'AI Email Agent', setup: 500, monthly: 350, description: 'Auto-draft replies, triage inbox, follow-up scheduling' }, crm_setup: { label: 'CRM Build and Migration', setup: 1500, monthly: 0, description: 'Full GHL build, pipeline setup, team training' } }
ComplexityMultiplier
Simple0.7x
Standard1.0x
Complex1.5x
Enterprise2.0x
export const COMPLEXITY_MULTIPLIERS = { simple: 1.0, // standard use case, clean data moderate: 1.25, // some custom logic, data migration complex: 1.5, // multi-location, legacy integrations enterprise: 2.0 // custom dev, dedicated support }
ComponentsDiscount
3+ services10% off
5+ services15% off
export const BUNDLE_DISCOUNTS = { 2: 0.05, // 5% off for 2 services 3: 0.10, // 10% off for 3 services 4: 0.15 // 15% off for 4+ services } export function calculateQuote( selectedServices: (keyof typeof SERVICES)[], complexity: keyof typeof COMPLEXITY_MULTIPLIERS ) { const multiplier = COMPLEXITY_MULTIPLIERS[complexity] const discount = BUNDLE_DISCOUNTS[Math.min(selectedServices.length, 4) as keyof typeof BUNDLE_DISCOUNTS] ?? 0 let totalSetup = 0 let totalMonthly = 0 const lineItems = selectedServices.map(key => { const service = SERVICES[key] const setup = Math.round(service.setup * multiplier) const monthly = Math.round(service.monthly * multiplier) totalSetup += setup totalMonthly += monthly return { ...service, setup, monthly } }) const discountAmount = Math.round(totalMonthly * discount) return { lineItems, totalSetup, totalMonthly, discountAmount, finalMonthly: totalMonthly - discountAmount, discountPercent: Math.round(discount * 100) } }

Claude-Powered Quote Generation

The quote generation endpoint takes a raw meeting transcript, sends it to Claude via the Anthropic API, and gets back a structured quote recommendation that maps the discussion to your exact service components and pricing.

// app/api/generate-quote/route.ts import Anthropic from '@anthropic-ai/sdk' import { SERVICES, COMPLEXITY_MULTIPLIERS, calculateQuote } from '@/lib/pricing' const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }) export async function POST(req: Request) { const { transcript } = await req.json() const serviceList = Object.entries(SERVICES) .map(([key, s]) => `- ${key}: ${s.label} (£${s.setup} setup, £${s.monthly}/mo) - ${s.description}`) .join('\n') const message = await client.messages.create({ model: 'claude-opus-4-5', max_tokens: 1024, messages: [{ role: 'user', content: `You are a pricing assistant. Analyse this sales call transcript and recommend which services to include in the quote. Available services: ${serviceList} Complexity levels: simple (standard), moderate (custom logic), complex (legacy integrations), enterprise (custom dev) Transcript: ${transcript} Respond with JSON only: { "services": ["service_key_1", "service_key_2"], "complexity": "simple|moderate|complex|enterprise", "reasoning": "one sentence explaining the recommendation", "notes": "any special requirements or flags from the transcript" }` }] }) const raw = (message.content[0] as any).text const parsed = JSON.parse(raw) const quote = calculateQuote(parsed.services, parsed.complexity) return Response.json({ ...quote, ...parsed }) }

Want a custom AI dashboard built for your business?

Live revenue, pipeline, content queue, and a pricing engine - deployed in a week.

Book a Call

Deployment: Git Push to Vercel

The deployment workflow is deliberately simple: push to the main branch and Vercel deploys automatically. No CI/CD config, no Docker, no SSH. Vercel detects the Next.js project, builds it, and deploys it to a global edge network. HTTPS is automatic. Custom domain setup takes 2 minutes.

Initial setup

  1. Push your project to a GitHub repository (private is fine)
  2. Go to vercel.com, import the repository
  3. Vercel auto-detects Next.js and sets the build command
  4. Add all environment variables in Project Settings > Environment Variables
  5. Deploy - first deploy takes 60-90 seconds

Environment variables in Vercel

# These go in Vercel dashboard, NOT in your code STRIPE_SECRET_KEY=sk_live_... GHL_API_KEY=eyJhbGci... GHL_LOCATION_ID=abc123xyz BEEHIIV_API_KEY=bh_... BEEHIIV_PUB_ID=pub_... ANTHROPIC_API_KEY=sk-ant-... DASHBOARD_PASSWORD=your-password AUTH_COOKIE_VALUE=random-32-char-string
Warning: In Next.js, any environment variable you want to access in server code must NOT be prefixed with NEXT_PUBLIC_. Variables prefixed with NEXT_PUBLIC_ are bundled into the browser JavaScript. API keys should never have that prefix.

Subsequent deploys

# Make changes, then: git add . git commit -m "add content queue section" git push origin main # Vercel picks it up automatically - live in ~60 seconds

If the automatic deploy doesn't trigger (rare but happens), use vercel --prod from the project directory as a fallback. Install the Vercel CLI with npm i -g vercel and run vercel login once.

Branch previews

Every non-main branch gets its own preview URL automatically. Push a dev branch and get your-project-dev.vercel.app. This makes testing changes safe - verify the preview URL works before merging to main.

Tip: Add your dashboard URL to your CLAUDE.md under Key File Locations. When Claude knows your dashboard URL, it can reference it in responses, and you can tell it "check my dashboard" as part of a larger workflow - for example, having it summarise the key metrics it can see from the live-data endpoint.
Next Guide
VPS Setup and Deployment