Dashboards are one of the best AI coding projects: conventional layout, visual output you can verify by looking, and a small security surface if it’s internal.
They’re also where people first discover that code which works with test data falls over with real data. That’s the part worth planning for.
What You’re Building
A dashboard for the project tracker from the CRUD tutorial: summary stats along the top, a chart of activity over time, a table of recent items, and a date-range filter.
Layout First, Data Second
Build the shell before wiring anything up. It’s faster to fix layout when there’s no data logic tangled into it.
Next.js App Router, TypeScript, Tailwind. Build a dashboard layout at /dashboard: fixed sidebar with nav, top bar with user menu, main content area. Responsive — sidebar collapses to a drawer under 768px.
In the content area, place four stat cards in a row, a full-width chart below, and a table below that. Use placeholder data for now. Keep it to as few files as possible.
Check it on a phone before continuing. Dashboard layouts break on mobile more than anything else, and it’s much cheaper to fix now.
Commit.
Stats — Aggregate in the Database
This is the step that determines whether your dashboard survives real usage.
The wrong version, which you’ll get by default:
const { data } = await supabase.from('projects').select('*')
const active = data.filter(p => p.status === 'active').length
const done = data.filter(p => p.status === 'done').length
Four stats, one query that fetches every row you own, then counts in JavaScript. Fine at 50 rows. At 50,000 you’re transferring the whole table to compute four numbers.
Ask for the right thing:
Replace the stat calculations with database aggregation. Write a Postgres function that returns total count, active count, completed count, and count created in the last 30 days for a given user ID, in one query. Call that instead of fetching rows. Filter by the authenticated user.
Same for the chart:
The activity chart must aggregate in SQL — group by day, count per day, for the selected range. Do not fetch individual rows and group them in JavaScript.
Charts
Add a line chart of projects created per day for the last 30 days using Recharts. Responsive container. Format dates on the x-axis as short month and day. Include a loading skeleton and an empty state for when there’s no data.
Two things to request explicitly, because they’re always missing:
Empty states. A chart with no data renders as an unlabelled empty box that looks broken.
Loading skeletons. Without them the layout jumps as each section arrives.
Every dashboard section needs three states: loading skeleton, empty state with helpful text, and error state with a retry.
Filters Without Refetching Everything
Add a date range filter with presets — 7 days, 30 days, 90 days, custom. Changing it must re-run the aggregation query server-side with the new range, not filter already-loaded data. Put the range in the URL as a search param so it survives refresh and can be shared.
The URL detail is worth having. It makes dashboard states linkable, which people want immediately.
The Security Bit
Dashboards leak in a specific way: aggregate queries feel anonymous, so ownership filters get skipped.
A count that includes other users’ rows is still a data leak — it tells you how many records exist. Worse, a “recent activity” table without an ownership filter shows the actual content.
Every query on this dashboard, including aggregates and the Postgres function, must filter by the authenticated user’s ID. List each query and show me where it filters.
If the dashboard is admin-only, that’s a different model — see vibe code an admin panel for role checks.
Performance
Once it works, these are the requests that keep it working:
Add database indexes on the columns used for filtering and sorting — user_id, created_at, status. Show me the SQL.
Cache the stat aggregation for 60 seconds. Dashboard numbers don’t need to be real-time.
Stream the sections independently with Suspense so the stats render before the chart finishes loading.
That last one changes the feel considerably — the page becomes useful immediately rather than after the slowest query.
The Checklist
- All aggregation happens in SQL, not JavaScript
- Every query filters by authenticated user
- Indexes on filter and sort columns
- Loading, empty, and error states on every section
- Filter state in the URL
- Responsive — tested on an actual phone
- Stats cached
- Tested with a few thousand rows, not five
That final point is worth doing deliberately. Generate 5,000 fake rows and reload. Problems that don’t exist with test data appear immediately.
Write a SQL script that inserts 5000 realistic fake projects for my user ID, spread across the last 90 days.
Next
A dashboard reads data. The natural next steps are an admin panel that manages it across users, or a REST API that exposes it to other apps.
Related reading: