TL;DR
The one-paragraph version before you dive in.
Paste the starter prompt into Lovable Build mode and get a staff-only KPI dashboard with stat cards, Recharts visualizations, date-range filters, and a pre-aggregated metrics_snapshots table. Total build time is roughly two hours with five follow-up prompts. Expected credit burn is 80–150 credits on a Pro $25/mo plan.
Setup checklist
Complete these steps in Lovable before pasting the starter prompt — takes about 5 minutes.
Cloud tab settings
Database
Stores metrics_snapshots, dashboard_filters, and the dashboard_overview() RPC that returns all KPI numbers in one round-trip.
- 1Click the + button next to Preview in the top toolbar to open the Cloud tab
- 2Click Database — you'll see an empty Table Editor
- 3Leave it empty for now — the starter prompt will create all tables, RLS policies, the has_role() function, and the dashboard_overview() RPC for you
Auth
Email+password staff login. Role (admin or staff) is stored in app_metadata so it's baked into the JWT and cannot be tampered with from the client.
- 1Cloud tab → Users & Auth
- 2Under Sign-in methods, confirm Email is enabled (it is by default)
- 3Toggle 'Allow new users to sign up' to OFF — you'll invite staff members manually from the Users page
- 4After the build, set each staff user's app_metadata to {"role": "staff"} from Cloud tab → Users & Auth → click the user row → Edit
Edge Functions
The daily refresh-metrics-snapshot function aggregates your real data and writes one row per metric per day, keeping dashboard queries fast and cheap.
- 1Cloud tab → Edge Functions
- 2No manual setup needed before pasting the starter prompt — follow-up prompt #1 will scaffold the function and pg_cron schedule for you
- 3After follow-up #1, click the function name here to view logs and confirm it ran successfully
Preflight checklist
- You're in a fresh Lovable project (Vite + React + TypeScript + Tailwind + shadcn/ui already scaffolded — that's the default)
- You're on Pro $25/mo — the full 7-route dashboard with charts and optional Edge Function follow-ups runs ~80–150 credits total, which exceeds the Free plan's ~30 monthly cap
- Your existing domain tables (orders, users, products) are in Lovable Cloud if you want the dashboard to report on real data — the starter prompt seeds 7 days of fake metrics_snapshots for the demo regardless
The starter prompt — paste this first
Copy this. Paste it into Lovable Build mode (the default chat at the bottom-left of the editor). Hit send.
Build a staff-only internal KPI dashboard. Stack: React + Vite + TypeScript + Tailwind + shadcn/ui (already scaffolded), React Router, Supabase JS client against Lovable Cloud.
## Database schema (create one migration)
Create these tables in the public schema and enable RLS on all of them:
1. `profiles` — id (uuid, FK to auth.users.id, cascade delete, primary key), full_name (text), email (text not null), role (text not null default 'staff', check role in ('admin','staff','viewer')), created_at (timestamptz default now()).
2. `metrics_snapshots` — id (uuid pk default gen_random_uuid()), metric (text not null, e.g. 'mrr_cents', 'active_users', 'orders_count', 'new_signups_7d'), value_numeric (numeric), value_jsonb (jsonb — for breakdowns), captured_at (timestamptz default now()), period (text check in ('day','week','month')). Add an index on (metric, captured_at).
3. `dashboard_filters` — id (uuid pk default gen_random_uuid()), user_id (uuid references auth.users.id), name (text), filter_jsonb (jsonb). RLS: own-row read/write only.
Create a SECURITY DEFINER plpgsql function `has_role(check_role text) RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER STABLE` that reads `(auth.jwt() -> 'app_metadata' ->> 'role')` and returns whether it matches check_role. NEVER use a SQL-language function — they get inlined by the optimizer and lose the security definer boundary.
Create a SECURITY DEFINER plpgsql function `dashboard_overview() RETURNS TABLE(total_users bigint, mrr_cents bigint, orders_today bigint, new_signups_7d bigint)` that queries your existing domain tables (or returns 0 if they don't exist yet). This gives the dashboard 4 KPI numbers in one round-trip.
RLS policies:
- profiles: SELECT by own row OR (has_role('admin') OR has_role('staff'))
- metrics_snapshots: SELECT by authenticated USING (has_role('staff') OR has_role('admin') OR has_role('viewer')); no INSERT/UPDATE/DELETE by authenticated users (writes only from service role or Edge Function)
- dashboard_filters: SELECT/INSERT/UPDATE/DELETE WHERE user_id = auth.uid()
Seed 7 days of fake metrics_snapshots rows (one row per metric per day for mrr_cents, active_users, orders_count, new_signups_7d) so charts render immediately.
## Layout
Create `src/layouts/DashboardLayout.tsx` — a two-column shell with a fixed left sidebar (220px) containing: logo at top, nav links (Overview, Revenue, Users, Activity, Settings), and a bottom-pinned user menu (email + Sign out). Main area scrolls with a DateRangeFilter pill (last 7d / 30d / 90d / custom) pinned at top-right. Apply a light mode default with shadcn ThemeProvider.
Create `src/components/StaffGuard.tsx` — calls supabase.auth.getUser(), reads app_metadata.role, shows a centered loading spinner while the session resolves, and redirects to /unauthorized if role is NOT in ('admin','staff','viewer'). Never read the role before the user object resolves.
## Pages and routes
Create these routes in src/App.tsx and page components:
- `/dashboard` — Overview: 4 StatCard components (Total Users, MRR, Orders Today, New Signups 7d) fetched from dashboard_overview() RPC, plus a revenue trend LineChart and a signups-by-day LineChart fetched from metrics_snapshots.
- `/dashboard/revenue` — MRR trend (line) + revenue by plan (stacked area), both from metrics_snapshots WHERE metric IN ('mrr_cents', ...) filtered by date range.
- `/dashboard/users` — Signups-by-day (bar) + active users by week (line).
- `/dashboard/activity` — Last 50 recent actions table (read from your existing domain tables, or show an empty state if not present).
- `/dashboard/settings` — DateRangeFilter defaults + saved filter view manager (reads/writes dashboard_filters).
- `/unauthorized` — one-line message with Sign out button.
## Components
Create these reusable components:
- `src/components/StatCard.tsx` — label, value, delta (with emerald-500 for positive / rose-500 for negative), sparkline (inline Recharts LineChart, no axes).
- `src/components/TimeSeriesChart.tsx` — Recharts ResponsiveContainer + LineChart/AreaChart wrapper. Accepts data, metric key, date range. Shows a shadcn Skeleton while loading. Shows an empty-state illustration when data is an empty array. Uses shadcn chart palette tokens.
- `src/components/DateRangeFilter.tsx` — Pill buttons for last 7d / 30d / 90d plus a calendar popover for custom range. On change, calls an onRangeChange callback that triggers query refetch.
- `src/components/SavedFilterChips.tsx` — reads dashboard_filters for current user, renders named chips, click to restore the saved range.
## Styling
Light mode default. Primary color: slate-900. Positive deltas: emerald-500. Negative deltas: rose-500. Four stat cards on desktop (2-up tablet, 1-up mobile). Chart lines use shadcn chart palette tokens. Table rows zebra-striped on hover.
Generate the migration first, confirm it looks correct, then generate the layout + StaffGuard, then the pages in order.What this prompt generates
- Creates a SQL migration with 3 tables (profiles, metrics_snapshots, dashboard_filters), has_role() SECURITY DEFINER function, dashboard_overview() RPC, all RLS policies, and 7 days of seed data
- Scaffolds DashboardLayout.tsx with sidebar nav, user menu, and DateRangeFilter, wrapped in StaffGuard
- Generates 5 page components (Overview, Revenue, Users, Activity, Settings) wired to React Router
- Builds reusable StatCard, TimeSeriesChart, DateRangeFilter, and SavedFilterChips components
- Wires all chart queries to metrics_snapshots via date-range filter — never raw domain tables
Paste into: Lovable Build mode (the default chat at the bottom-left of the editor)
Expected output
What Lovable will generate after the starter prompt runs successfully.
Files
supabase/migrations/0001_internal_dashboard_schema.sql3 tables + RLS + has_role() + dashboard_overview() RPC + 7-day seed data
src/layouts/DashboardLayout.tsxSidebar + DateRangeFilter pill + user menu shell
src/components/StaffGuard.tsxReads JWT app_metadata.role, shows spinner while loading, redirects non-staff to /unauthorized
src/components/StatCard.tsxKPI card with label, value, delta, and sparkline
src/components/TimeSeriesChart.tsxRecharts wrapper with loading skeleton and empty state
src/components/DateRangeFilter.tsxPill buttons + custom calendar popover, fires onRangeChange
src/components/SavedFilterChips.tsxReads and restores per-user saved date ranges from dashboard_filters
src/hooks/useDashboardMetrics.tsTanStack Query hook that fetches from metrics_snapshots with staleTime: 60_000
src/pages/Dashboard.tsxOverview with 4 StatCards + 2 charts
src/pages/Revenue.tsxMRR trend + revenue-by-plan stacked area chart
src/pages/Users.tsxSignups-by-day + active-users-by-week charts
src/pages/Activity.tsxRecent activity table (last 50 rows)
src/pages/Settings.tsxFilter defaults + saved views manager
src/pages/Unauthorized.tsxFallback for non-staff users
Routes
Overview — 4 KPI cards + revenue trend + signups chart
MRR trend + revenue by plan breakdown
Signups by day + active users by week
Recent system activity table
Date-range defaults + saved views
Shown when a non-staff user lands on /dashboard/*
DB Tables
profiles| Column | Type |
|---|---|
| id | uuid primary key references auth.users(id) |
| full_name | text |
| text not null | |
| role | text not null default 'staff' |
| created_at | timestamptz default now() |
RLS: Own-row SELECT for everyone; staff and admin can SELECT all rows (read-only)
metrics_snapshots| Column | Type |
|---|---|
| id | uuid primary key default gen_random_uuid() |
| metric | text not null |
| value_numeric | numeric |
| value_jsonb | jsonb |
| captured_at | timestamptz default now() |
| period | text |
RLS: SELECT by staff/admin/viewer via has_role(); no INSERT/UPDATE/DELETE for authenticated users — writes only via service role or Edge Function
dashboard_filters| Column | Type |
|---|---|
| id | uuid primary key default gen_random_uuid() |
| user_id | uuid references auth.users(id) |
| name | text |
| filter_jsonb | jsonb |
RLS: Own-row read/write only — each staff member saves their own date-range views
Components
StaffGuardSession-aware role check — shows spinner while loading, redirects non-staff
StatCardKPI card with value, delta indicator, and inline sparkline
TimeSeriesChartRecharts wrapper with loading skeleton, empty state, and shadcn chart palette
DateRangeFilterDate preset pills + custom calendar popover
SavedFilterChipsPer-user named filter views stored in dashboard_filters
Follow-up prompts
Paste these into Agent Mode one by one, in order, after the starter prompt finishes.
Wire a daily metrics refresh cron
Daily pg_cron Edge Function that aggregates real domain tables into metrics_snapshots at 03:00 UTC
Create a Supabase Edge Function at `supabase/functions/refresh-metrics-snapshot/index.ts` that:
1. Queries the real domain tables (orders, users, products if they exist) to compute: total_users, mrr_cents (sum of paid orders today if available), orders_count (today's order count), new_signups_7d (users created in last 7 days).
2. For each metric, inserts one row into metrics_snapshots with period='day' and captured_at=now().
3. Returns a JSON summary of what was written.
Then add a pg_cron job to schedule it: in the migration or a new SQL file, run `SELECT cron.schedule('refresh-metrics-daily', '0 3 * * *', $$SELECT net.http_post(url:='<YOUR_EDGE_FUNCTION_URL>/refresh-metrics-snapshot', headers:='{"Authorization": "Bearer " || current_setting(''app.service_role_key'')}')$$);`. Document that the URL must be replaced with the actual Edge Function URL from Cloud tab → Edge Functions after deploy.When to use: After 5+ days of real data exists in your domain tables
Connect a warehouse panel (Snowflake or BigQuery)
Warehouse-connector panel on Revenue page via Edge Function proxy with named queries only
Add a warehouse query panel to /dashboard/revenue. Steps:
1. In Cloud tab → Integrations, enable the Snowflake (or BigQuery) App connector for this project.
2. Create an Edge Function at `supabase/functions/warehouse-query/index.ts` that accepts a POST body with `{query_name: string, params: object}`. The function runs a SAVED named query (hardcoded switch statement — never accept free-form SQL from the client) against the connector and returns shaped JSON. Add WAREHOUSE_NAMED_QUERY_TIMEOUT=30000 to Cloud Secrets.
3. Add a new WarehouseChart component in src/components/WarehouseChart.tsx that calls the warehouse-query Edge Function with the current date range and renders the result in a Recharts LineChart.
4. Render WarehouseChart below the existing Recharts charts on /dashboard/revenue with a 'Warehouse data' label and a 'Last refreshed' timestamp.When to use: When your real data lives in Snowflake, BigQuery, or Databricks and is too large for Supabase
Add CSV export per chart
Date-stamped CSV export button on every chart card, routed through an Edge Function
Create an Edge Function at `supabase/functions/dashboard-export-csv/index.ts` that:
1. Accepts POST with `{metric: string, from_date: string, to_date: string}` in the body.
2. Queries metrics_snapshots WHERE metric = $1 AND captured_at BETWEEN $2 AND $3.
3. Returns a text/csv response with headers `metric,value_numeric,captured_at,period` and Content-Disposition: attachment; filename="{metric}_{from}_{to}.csv".
In each TimeSeriesChart card, add an 'Export CSV' button (shadcn Button, size sm, variant outline) at the card's top-right. On click, POST to the edge function with current metric name and date range, then trigger a browser download via `URL.createObjectURL(blob)`.When to use: When stakeholders ask to email the numbers or drop them into a spreadsheet
Add saved date-range filters per user
Per-user named date-range views that persist across sessions and appear in both the filter pill and settings page
Extend the DateRangeFilter and SavedFilterChips components:
1. Add a 'Save current view' button inside DateRangeFilter. On click, open a shadcn Dialog with a text input for the view name. On confirm, insert a row into dashboard_filters with `{name, filter_jsonb: {range_type, custom_start, custom_end}}`.
2. In SavedFilterChips, render each saved view as a shadcn Badge with an X button. Clicking the badge restores the range. Clicking X deletes the row.
3. On /dashboard/settings, render a full list of all saved views (name + range summary + delete button) from the current user's dashboard_filters rows.When to use: Once people use the dashboard daily and want to switch between 'This week' and 'Last quarter' in one click
Add an AI weekly summary widget
AI-generated 3-bullet weekly summary card at the top of the Overview page
Create an Edge Function at `supabase/functions/weekly-summary/index.ts` that:
1. Queries the last 7 days of metrics_snapshots rows grouped by metric.
2. Formats them into a plain-text summary: 'Metrics for the week of {date}: mrr_cents={value}, active_users={value}, orders_count={value}, new_signups_7d={value}. Previous week: ...'.
3. Calls Lovable AI (Gemini 3 Flash via the built-in AI panel) or directly calls the Anthropic API with key from Cloud Secrets (ANTHROPIC_API_KEY) using Claude Haiku 4.5 to generate a 3-bullet plain-English summary.
4. Returns the 3-bullet string.
In src/pages/Dashboard.tsx, add a shadcn Card at the very top of the page with title 'This week at a glance'. On mount, POST to weekly-summary and render the result. Show a shadcn Skeleton while loading.When to use: When you want exec-level plain-English insight without reading 4 separate charts
Add drill-down from KPI card to raw rows
Clickable KPI cards that open a drill-down Sheet showing the underlying 100 rows
Make each StatCard clickable. On click, open a shadcn Sheet (right drawer, 560px wide) with:
1. A title like 'Last 100 orders contributing to this KPI'.
2. A table showing the underlying domain rows (e.g., orders for mrr_cents, users for new_signups_7d).
3. The same has_role('staff') RLS check that governs metrics_snapshots governs the underlying tables — the Sheet query fails cleanly with an error message if the domain table doesn't exist or lacks a staff-read policy.
Create a DrillDownSheet component in src/components/DrillDownSheet.tsx that accepts `{metric, dateRange, isOpen, onClose}` and maps each metric name to its source table and columns.When to use: Once stakeholders start asking 'which orders made up that MRR number?'
Common errors
Real error strings you'll see. Find yours, paste the fix prompt.
Charts render fine in preview but show 0 everywhere on the published siteLovable's preview uses the service-role Supabase key, which bypasses RLS. Your production site uses the anon/authenticated key, which respects RLS. Your metrics_snapshots table has RLS enabled but no SELECT policy for authenticated users.
Add an RLS policy on metrics_snapshots: `CREATE POLICY staff_read_metrics ON metrics_snapshots FOR SELECT TO authenticated USING (has_role('staff') OR has_role('admin') OR has_role('viewer'));`. Confirm has_role() is the SECURITY DEFINER plpgsql variant reading from JWT app_metadata, NOT a SQL function reading from the profiles table. Re-test in the published site (not the preview).supabaseUrl is required — error on the published site, not in previewVITE_SUPABASE_URL and VITE_SUPABASE_PUBLISHABLE_KEY were not set in your Vercel or Netlify environment variables before the build. These values are baked in at build time, not injected at runtime.
Manual fix
Go to Vercel Dashboard → Settings → Environment Variables. Add VITE_SUPABASE_URL, VITE_SUPABASE_PUBLISHABLE_KEY, and VITE_SUPABASE_PROJECT_ID from Cloud tab → Integrations → Supabase → connection details. Trigger a new deployment (the env vars only apply to new builds, not the already-deployed one).
Dashboard page takes 8+ seconds to load and Cloud credit usage spikesA chart component is querying a raw domain table (e.g., SELECT * FROM orders) and aggregating in JavaScript. Recharts re-runs that query on every filter change, scanning the full table each time.
Replace the raw query in TimeSeriesChart with a fetch from `metrics_snapshots` filtered by metric and captured_at range. If metrics_snapshots is empty, create the refresh-metrics-snapshot Edge Function (follow-up #1) to populate it. Add an index on `metrics_snapshots(metric, captured_at)`. Wrap the fetch in a TanStack Query call with `staleTime: 60_000` so filter changes within the same minute don't trigger a refetch.
permission denied for table orders when a staff user opens /dashboard/revenueYour orders table has RLS enabled with policies for the customer or admin role only. Staff users have no SELECT policy on the domain table the Revenue chart is trying to query.
Run in SQL Editor: `CREATE POLICY staff_read_orders ON orders FOR SELECT TO authenticated USING (has_role('staff') OR has_role('admin'));`. Apply the same pattern to every domain table the dashboard touches. Long-term solution: only read from metrics_snapshots (which already has the correct policy) rather than raw domain tables.Snowflake connector returns 'credentials not found' in productionLovable's App connectors work in the deployed app but must be enabled at the Workspace level, not just configured in a chat session. The connector credential is also bound to the domain — rotating it or deploying to a new domain breaks the OAuth refresh token.
Manual fix
Cloud tab → Integrations → Snowflake → confirm the 'Enabled for this project' toggle is ON. Re-enter or rotate the connector credential. Redeploy the app so the new OAuth refresh token is bound to the production domain. Repeat after any custom domain change.
Cost reality
What this build actually costs — no surprises on your card.
Recommended Lovable plan
Pro $25/mo recommended — Free plan's ~30 monthly credits cover a single-page demo but not the full 6-route dashboard with charts, cron, and export follow-ups
Monthly run cost breakdown
~80–150 credits for starter + 3 follow-ups; add ~175 more if you do all 7 follow-ups total| Item | Cost |
|---|---|
| Lovable Pro Can drop back to Free once the dashboard is launched — the app continues to run on Cloud | $25/mo while iterating |
| Supabase / Lovable Cloud Internal-dashboard traffic rarely exceeds 1K page views/mo; 500MB DB is more than enough for metrics_snapshots rows | $0/mo at MVP scale |
| Warehouse compute (optional) Snowflake/BigQuery billed by query — always use saved/parameterized named queries via Edge Function, never free-form SQL | $0–50/mo |
| Custom domain Point a subdomain (e.g., dashboard.yourcompany.com) at the published Lovable app | ~$10–15/yr |
Scaling notes: An internal dashboard rarely scales to the point where Supabase Free tier becomes the bottleneck — 50 staff viewers will never hit the 50K MAU or 500MB DB cap. The only realistic cost spike is wiring a warehouse connector that runs free-form SQL — one bad query can cost $5–50 in Snowflake/BigQuery compute. Always use named/parameterized queries inside the warehouse-query Edge Function and set a hard 30-second query timeout.
Production checklist
Steps to take before you share the URL with real users.
Domain & SSL
Point a custom subdomain at your published Lovable app
Publish the app via the Publish icon top-right → Settings → Custom domain. Add a CNAME record at your DNS provider pointing dashboard.yourcompany.com to your lovable.app URL. SSL is auto-provisioned.
Auth hardening
Set staff role in app_metadata for every team member
Cloud tab → Users & Auth → click each user row → Edit → set app_metadata to {"role": "staff"} (or "admin"). Role must be in app_metadata, NOT user_metadata — user_metadata is client-writable and can be spoofed.
Disable new user sign-up
Cloud tab → Users & Auth → Sign-in methods → toggle 'Allow new users to sign up' to OFF so only manually-invited users can access the dashboard.
Monitoring
Verify the daily cron ran successfully
Cloud tab → Edge Functions → refresh-metrics-snapshot → Logs. Confirm one run per day at ~03:00 UTC with a 200 status. Set up a Resend alert email from inside the Edge Function if the metrics computation fails.
Backups
Confirm daily backups are enabled
Cloud tab → Database → Backups. Lovable Cloud takes daily backups retained up to ~14 days. Verify the most recent backup timestamp shows today's date.
Frequently asked questions
Does this work on the Free Lovable plan for a tiny team?
The starter prompt alone runs about 30–50 credits, which is close to the Free plan's ~30 monthly cap. You can build the basic dashboard on Free but you'll likely exhaust credits before finishing the date-range filter and all six routes. For a production-ready internal dashboard with the cron follow-up, Pro $25/mo is the realistic minimum.
Why do I need metrics_snapshots if my data is already in Supabase?
Without a pre-aggregation layer, every chart load triggers a full table scan (e.g., SELECT SUM(total_cents) FROM orders). That's fine with 100 rows in preview, but with 50K real orders it adds 3–8 seconds of page load time and can burn Lovable Cloud bandwidth credits at scale. The metrics_snapshots layer stores one row per metric per day — queries hit an indexed, tiny table instead of your full domain tables. The daily refresh cron (follow-up #1) keeps those numbers current.
Can I connect Snowflake or BigQuery without writing SQL myself?
Yes — Lovable's App connectors for Snowflake and BigQuery appear under Cloud tab → Integrations and can be enabled with a few clicks. You still need to write the named queries that the warehouse-query Edge Function will run, but the starter prompt and follow-up #2 give you the Edge Function template. The critical rule: never pass free-form SQL from the frontend — only call named/parameterized queries from the Edge Function to keep warehouse compute costs predictable.
How do I prevent Recharts from re-rendering on every keystroke in the date filter?
Wrap your chart data fetches in TanStack Query with staleTime: 60_000 (one minute). This means rapid filter changes within the same minute reuse the cached result instead of firing a new query. For the custom date range input, add a 300ms debounce before the filter value updates the query key. Both patterns are noted in the starter prompt for TimeSeriesChart.
What's the cheapest way to share a dashboard with a non-staff stakeholder?
The lowest-friction option is to create them a Lovable Cloud user with role='viewer', which gives them read-only access through the normal sign-in flow. If they should not have a login, follow-up #7 (embed mode) generates a signed-JWT embed URL you can paste into Notion or a client portal — the URL is time-limited and renders the chart without exposing the full dashboard.
Can I embed this dashboard in Notion or another tool we already use?
Yes — follow-up #7 adds a /embed/:dashboardId?token= route that validates a short-lived JWT, renders a chart without sidebar or auth UI, and is iframeable. Paste the embed URL into a Notion page using the /embed block. The signed token means the URL works for anyone with the link for its validity period (e.g., 24 hours), then expires. For permanent embeds, generate a new token daily via a scheduled Edge Function.
When should I move off Lovable and use Metabase or Retool instead?
Move to Metabase if your team wants ad-hoc SQL exploration alongside dashboards — Metabase's query builder is purpose-built for that and Lovable is not. Move to Retool if you need write-heavy admin forms alongside dashboards in one product (Retool combines both). Stay in Lovable if your data is already in Supabase or Lovable Cloud, you have fewer than 10 staff viewers, and you want a custom-branded KPI surface nobody outside your team will ever see — the run cost is near $0 forever and you control every pixel. If your build outgrows this prompt kit and you need custom architecture, RapidDev builds production-grade Lovable apps at $13K-$25K — book a free 30-minute consultation at rapidevelopers.com.
Need a production-grade version?
RapidDev builds production-grade Lovable apps at $13K–$25K. Book a free 30-minute consultation.
Book a free consultation30-min call. No commitment.