# How to Build an Internal Dashboard with Lovable

- Tool: How to Build with Lovable
- Difficulty: Intermediate
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

Build an internal admin dashboard in Lovable with Supabase Postgres views, KPI metric cards, interactive charts, a drill-down DataTable, and a real-time metrics ticker. The result is a live business intelligence panel your whole team can use — built without touching a server.

## Before you start

- Lovable Pro account
- Supabase project with at least one existing table containing business data
- VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY in Cloud tab → Secrets
- A profiles table with a role column (admin/member) linked to auth.users
- Familiarity with Lovable's Cloud tab and the Publish flow

## Step-by-step guide

### 1. Create Postgres views for KPI aggregation

Ask Lovable to generate SQL views in Supabase that pre-aggregate your key metrics. Views run on the database and return a single row of numbers — much faster than running aggregation queries in React.

```
Create Supabase Postgres views for a business dashboard. Assume these source tables exist (or create mock versions):
- orders: id, user_id, total, status, created_at
- users: id, email, created_at
- support_tickets: id, user_id, status, resolved_at, created_at

Create these views:
1. kpi_summary — columns: total_revenue (sum of orders.total where status='paid'), revenue_last_period, active_users (count distinct users last 30 days), new_users_this_period, open_tickets (count where status='open'), tickets_last_period. Include period-over-period delta as a percentage.
2. daily_revenue — columns: day (date), revenue (sum), order_count for the last 90 days
3. user_signups_daily — columns: day, signups for the last 90 days

Grant SELECT on these views to the authenticated role so RLS-authenticated users can read them.
```

> Pro tip: Add a SECURITY DEFINER clause to the views so they bypass RLS and aggregate across all rows — then protect access at the route level with role checking rather than per-row policies.

**Expected result:** Lovable generates the SQL and runs it via Supabase migrations. You can query SELECT * FROM kpi_summary in the Supabase SQL editor and see one row of aggregated numbers.

### 2. Build KPI cards with delta badges

Create a row of KPI cards at the top of the dashboard. Each card shows the current metric value, a trend percentage compared to last period, and an up/down arrow badge color-coded green or red.

```
import { useQuery } from '@tanstack/react-query'
import { supabase } from '@/integrations/supabase/client'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { TrendingUp, TrendingDown } from 'lucide-react'

type KpiSummary = {
  total_revenue: number
  revenue_last_period: number
  active_users: number
  new_users_this_period: number
  open_tickets: number
  tickets_last_period: number
}

function delta(current: number, previous: number) {
  if (!previous) return 0
  return Math.round(((current - previous) / previous) * 100)
}

export function KpiCards() {
  const { data } = useQuery<KpiSummary>({
    queryKey: ['kpi_summary'],
    queryFn: async () => {
      const { data, error } = await supabase.from('kpi_summary').select('*').single()
      if (error) throw error
      return data
    },
    staleTime: 60_000,
  })

  const cards = [
    { label: 'Revenue', value: data ? `$${data.total_revenue.toLocaleString()}` : '—', pct: data ? delta(data.total_revenue, data.revenue_last_period) : 0 },
    { label: 'Active Users', value: data?.active_users.toLocaleString() ?? '—', pct: 0 },
    { label: 'New Users', value: data?.new_users_this_period.toLocaleString() ?? '—', pct: 0 },
    { label: 'Open Tickets', value: data?.open_tickets.toLocaleString() ?? '—', pct: data ? delta(data.open_tickets, data.tickets_last_period) : 0 },
  ]

  return (
    <div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
      {cards.map((c) => (
        <Card key={c.label}>
          <CardContent className="pt-6">
            <p className="text-sm text-muted-foreground">{c.label}</p>
            <p className="mt-1 text-2xl font-bold">{c.value}</p>
            {c.pct !== 0 && (
              <Badge variant={c.pct > 0 ? 'default' : 'destructive'} className="mt-2 gap-1">
                {c.pct > 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
                {Math.abs(c.pct)}%
              </Badge>
            )}
          </CardContent>
        </Card>
      ))}
    </div>
  )
}
```

> Pro tip: Wrap the KpiCards query with a refetchInterval of 5 * 60 * 1000 so the numbers stay fresh during long dashboard sessions without requiring a manual page refresh.

**Expected result:** Four metric cards appear at the top of the dashboard showing current values. Cards with positive deltas show a green upward badge; negative deltas show red downward badges.

### 3. Add trend charts from the daily views

Render line charts for daily revenue and user signups using the Postgres views created in step 1. Each chart reads from its view and renders with Recharts inside a shadcn/ui Card.

```
Build a TrendsSection component at src/components/dashboard/TrendsSection.tsx.

Requirements:
- Fetch data from daily_revenue view (day, revenue, order_count)
- Fetch data from user_signups_daily view (day, signups)
- Render two side-by-side Cards on md+ screens, stacked on mobile
- Left card: AreaChart for revenue with a gradient fill in the primary color
- Right card: BarChart for daily signups
- Both charts: XAxis with date labels formatted as 'MMM d' using date-fns format(), YAxis, Tooltip, ResponsiveContainer height={240}
- Show Skeleton placeholders while loading
- Add a small Select in each card header to switch between 30d / 60d / 90d windows — update the Supabase query with a .gte('day', cutoffDate) filter
```

**Expected result:** Two charts appear below the KPI cards. The revenue chart shows an area gradient; the signups chart shows daily bars. The period selector re-fetches data for the chosen window.

### 4. Build the DataTable with drill-down Sheet

Add a DataTable for the most recent orders. Clicking any row fetches the full order record and opens a Sheet panel with complete order details, line items, and customer info.

```
Build an OrdersTable component at src/components/dashboard/OrdersTable.tsx.

Requirements:
- Fetch latest 100 orders from Supabase: select id, user_id, total, status, created_at. Join with users to get email.
- Use TanStack Table v8 with these columns: Order ID (truncated), Customer Email, Total (formatted as currency), Status (shadcn/ui Badge: paid=green, pending=yellow, failed=red), Date
- Enable client-side sorting on all columns
- Add a search input above the table filtering by email or order ID
- On row click, fetch the full order from Supabase including a hypothetical order_items sub-table, then open a shadcn/ui Sheet
- Sheet content: customer name and email, order status Badge, total, line items list (product name, qty, unit price), created_at
- Add a CSV export button that downloads the visible rows as a CSV file using a Blob URL
```

> Pro tip: Implement server-side pagination by fetching rows in pages of 25 using Supabase's .range(from, to) method — this keeps the initial page load fast even when there are thousands of orders.

**Expected result:** A sortable, searchable orders table appears on the dashboard. Clicking any row slides open the Sheet showing the full order details. The CSV export button downloads all visible rows.

### 5. Add the real-time metrics ticker

Build a scrolling ticker at the top of the dashboard that shows live events — new orders, signups, resolved tickets — using Supabase Realtime subscriptions.

```
Build a MetricsTicker component at src/components/dashboard/MetricsTicker.tsx.

Requirements:
- Create a dashboard_events table in Supabase: id, event_type (new_order|new_user|ticket_resolved), message (text), metadata (jsonb), created_at. Enable Realtime on it.
- Subscribe to INSERT events on dashboard_events using supabase.channel('dashboard_events').on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'dashboard_events' }, handler).subscribe()
- Maintain a local state array of the last 20 events (prepend new events, slice to 20)
- Render a horizontal scrolling ticker bar at the top of the dashboard
- Each ticker item shows: a colored dot by event_type (new_order=green, new_user=blue, ticket_resolved=purple), the message text, and a relative time
- Animate new items sliding in from the right using a CSS transition
- Unsubscribe on component unmount
```

**Expected result:** A scrolling ticker bar appears at the top of the dashboard. When you insert a row into dashboard_events in Supabase's Table Editor, the new event appears in the ticker within one second.

### 6. Add role-based route protection

Protect the dashboard route so only users with the admin role in the profiles table can access it. Non-admin users are redirected to a permission denied page.

```
Add role-based route protection to the dashboard.

Requirements:
- Create a useAdminGuard hook at src/hooks/useAdminGuard.ts that:
  1. Gets the current session from supabase.auth.getSession()
  2. Fetches the profile row: supabase.from('profiles').select('role').eq('id', userId).single()
  3. Returns { isAdmin: boolean, isLoading: boolean }
- Wrap the Dashboard page component: if isLoading show a full-page Skeleton; if !isAdmin redirect to '/unauthorized'
- Create a simple /unauthorized page with a Card explaining the user doesn't have admin access and a Button to go back to the home page
- Ensure the profiles table has RLS: users can only SELECT their own row
```

> Pro tip: Store the role in the user's JWT claims using a Supabase Auth hook so you can read it from the session without an extra database round-trip on every route load.

**Expected result:** Logging in as a non-admin user and navigating to /dashboard redirects immediately to /unauthorized. Admin users see the full dashboard.

## Complete code example

File: `src/hooks/useAdminGuard.ts`

```typescript
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { supabase } from '@/integrations/supabase/client'

export function useAdminGuard() {
  const [isAdmin, setIsAdmin] = useState(false)
  const [isLoading, setIsLoading] = useState(true)
  const navigate = useNavigate()

  useEffect(() => {
    let mounted = true

    async function check() {
      const { data: { session } } = await supabase.auth.getSession()
      if (!session) {
        navigate('/login')
        return
      }

      const { data: profile, error } = await supabase
        .from('profiles')
        .select('role')
        .eq('id', session.user.id)
        .single()

      if (!mounted) return

      if (error || !profile || profile.role !== 'admin') {
        setIsAdmin(false)
        setIsLoading(false)
        navigate('/unauthorized')
        return
      }

      setIsAdmin(true)
      setIsLoading(false)
    }

    check()
    return () => { mounted = false }
  }, [navigate])

  return { isAdmin, isLoading }
}
```

## Common mistakes

- **Querying raw tables for KPI numbers instead of using views** — Running COUNT and SUM on large tables from the client causes slow load times and multiple redundant queries. Fix: Create Postgres views or functions that return pre-aggregated numbers so each KPI card fires one fast SELECT.
- **Subscribing to Realtime without unsubscribing on unmount** — Leaving open channels causes memory leaks and duplicate event handlers when the component re-mounts. Fix: Always return a cleanup function from useEffect that calls supabase.removeChannel(channel).
- **Fetching all orders into a DataTable without pagination** — Loading thousands of rows at once blocks the main thread and makes the page feel unresponsive. Fix: Use Supabase's .range(0, 24) pagination and implement server-side paging in TanStack Table.
- **Storing the admin role check only on the client** — A client-only redirect can be bypassed by navigating directly to the URL. Sensitive data is still exposed via Supabase queries. Fix: Enforce access at the Supabase RLS level by adding policies that check for the admin role, not just at the React router level.

## Best practices

- Use Postgres views for metric aggregation — they keep query logic in the database where it belongs and simplify the React code.
- Enable Realtime only on tables that need it — each active subscription counts against Supabase's concurrent connection limit.
- Use staleTime in React Query to avoid re-fetching KPI cards on every focus event — KPI data rarely needs sub-minute freshness.
- Add database indexes on the columns used in your view WHERE clauses — created_at, status, and user_id are common candidates.
- Test RLS policies with two different user roles before deploying — admin and regular user — to catch policy gaps early.
- Use shadcn/ui Skeleton components for all loading states to prevent layout shift as data loads.
- Scope the Realtime subscription to the minimum table and event type needed — use filter: 'event_type=eq.new_order' to reduce noise.
- Log all admin actions (exports, role changes, data deletions) to an audit_log table for compliance and debugging.

## Frequently asked questions

### Do I need to create new tables or can the dashboard use my existing ones?

You can use your existing tables. Create Postgres views on top of them that aggregate the metrics you want. The dashboard queries the views rather than the raw tables, so your data structure stays unchanged.

### How do I make the real-time ticker work?

You need to enable Realtime on the dashboard_events table in Supabase (Database → Replication → toggle the table). Then, whenever a key event happens (new order, new signup), insert a row into dashboard_events. The ticker's Supabase Realtime subscription picks it up instantly.

### Can I show data from multiple Supabase tables in one KPI card?

Yes. Create a Postgres view that JOINs or aggregates across multiple tables and returns a single-row result. The KPI card queries the view with a .single() call and reads each column as a separate metric.

### How do I restrict the dashboard to admins only?

Add a role column to your profiles table, set it to 'admin' for the appropriate users, and use the useAdminGuard hook shown in step 6. Also add RLS policies to your metric views so they only return data to admin-role users for defense in depth.

### Why are my KPI numbers updating slowly?

If your source tables are large, the Postgres views may be running expensive aggregations on every query. Add indexes on the columns used in WHERE clauses (status, created_at) and consider using materialized views refreshed periodically for the heaviest aggregations.

### Can I embed this dashboard inside another application?

Lovable apps are standalone React SPAs. You can iframe the published URL, but the embedded page still requires the user to be authenticated. For truly embeddable widgets, consider building a separate public endpoint that returns pre-computed JSON and renders charts without auth.

### What happens to the real-time ticker when the user loses network connection?

The Supabase Realtime client will attempt to reconnect automatically. Add an onError handler to the channel subscription to show a warning badge on the ticker when the connection is lost, and hide it when the connection is restored.

### Is RapidDev able to help customize this dashboard for my specific business metrics?

Yes. RapidDev can help you map your existing data model to the views and chart structure, configure RLS for your team, and add custom metrics specific to your industry.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/internal-dashboard
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/internal-dashboard
