Admin dashboards in 50 lines of code
Founders ask me what to put in their admin dashboard. The answer is almost always: less than you think, in fewer lines of code than you think, behind a much simpler login than you think.
The pattern I use across MVPs is fifty lines of Next.js. Password-gated through middleware. Five KPI cards. One chart. Done.
Here's the whole thing.
The wrong instinct
When founders set out to build an admin dashboard, they reach for one of:
- Retool ($30+/seat/month) — overkill for a single-founder tool.
- A full CMS (Strapi, Directus, Payload) — months of setup for capabilities you don't need.
- Building it inside their main user-account auth system — couples admin to the user system, leaks admin existence to the public, and makes "I forgot my admin password" a database problem.
What you actually want is: a private URL that you, the founder, can hit, that shows the numbers that decide whether to keep going.
The right shape
/admin ← protected route
└── KPI cards (DAU, signups, activations, costs)
└── Chart (retention by cohort, or top-features-used)
Authentication is HTTP Basic Auth via Next.js middleware. The browser prompts for username/password. You type both. You're in. No password reset flow, no UI to maintain, no database table.
This is genuinely correct for an MVP. The only person logging in is you. There is one password. It's strong because you generated it with openssl rand -base64 32. Done.
The implementation
1. Add to .env.local and to Vercel:
ADMIN_PASSWORD=<openssl rand -base64 32>
FEATURE_ADMIN=true
2. middleware.ts at the project root:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(req: NextRequest) {
if (!req.nextUrl.pathname.startsWith('/admin')) {
return NextResponse.next();
}
if (process.env.FEATURE_ADMIN !== 'true') {
return new NextResponse('Not Found', { status: 404 });
}
const auth = req.headers.get('authorization');
if (auth?.startsWith('Basic ')) {
try {
const decoded = atob(auth.slice(6));
const [user, pass] = decoded.split(':');
if (user === 'admin' && pass === process.env.ADMIN_PASSWORD) {
return NextResponse.next();
}
} catch {}
}
return new NextResponse('Authentication required', {
status: 401,
headers: { 'WWW-Authenticate': 'Basic realm="Admin"' },
});
}
export const config = {
matcher: '/admin/:path*',
};
3. app/admin/page.tsx:
import { unstable_cache } from 'next/cache';
import { db } from '@/lib/db';
export const revalidate = 60;
const getKpis = unstable_cache(async () => {
const [dau, signups7d, totalUsers, aiCalls7d] = await Promise.all([
db.execute('SELECT COUNT(DISTINCT user_id) FROM events WHERE created_at > NOW() - INTERVAL 1 DAY'),
db.execute('SELECT COUNT(*) FROM users WHERE created_at > NOW() - INTERVAL 7 DAY'),
db.execute('SELECT COUNT(*) FROM users'),
db.execute('SELECT COUNT(*) FROM events WHERE name = "ai_call" AND created_at > NOW() - INTERVAL 7 DAY'),
]);
return { dau, signups7d, totalUsers, aiCalls7d };
}, ['admin-kpis'], { revalidate: 60 });
export default async function AdminPage() {
const k = await getKpis();
return (
<div className="max-w-6xl mx-auto p-8">
<h1 className="text-3xl font-semibold mb-8">Admin</h1>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Kpi label="Daily active" value={k.dau} />
<Kpi label="Signups (7d)" value={k.signups7d} />
<Kpi label="Total users" value={k.totalUsers} />
<Kpi label="AI calls (7d)" value={k.aiCalls7d} />
</div>
</div>
);
}
function Kpi({ label, value }: { label: string; value: number }) {
return (
<div className="card bg-base-200 border border-base-300">
<div className="card-body p-5">
<div className="text-xs uppercase opacity-60 tracking-widest">{label}</div>
<div className="text-3xl font-semibold mt-1">{value}</div>
</div>
</div>
);
}
That's about 50 lines including the middleware. You have an admin dashboard.
What KPIs to put there
This is more important than the implementation. The wrong KPIs feel rigorous and tell you nothing. The right KPIs change behavior.
For most MVPs, the right four-to-five are:
- Daily active users (DAU) — are people coming back?
- Signups in the last 7 days — is acquisition working?
- Activation rate — what percentage of new users hit the "aha" event (whatever your core action is)?
- AI cost per user (if applicable) — is the unit economics sane?
- Top 5 features used — where are users spending time?
Vanity metrics that founders insist on but don't move decisions:
- Total signups (only matters with a denominator)
- Total page views (decoupled from outcomes)
- Time on site (correlates with confusion as much as engagement)
Pick four. Maybe five if you have a payment flow (then add MRR or paid conversion). More than five and you're building a Looker dashboard, not a founder tool.
The "404 not 403" rule
Critical: when an unauthenticated request hits /admin, the response should be 404 Not Found, not 403 Forbidden.
403 announces: "this URL exists, you just aren't allowed to see it." That's an invitation for someone to start guessing your password.
404 announces: "this URL doesn't exist." It removes the surface entirely. The implementation: when FEATURE_ADMIN is false, return 404; when it's true and no auth, return 401 with the WWW-Authenticate header (which the browser uses to prompt). The 401 only fires for the password prompt — anyone visiting from a shared link without credentials gets the prompt and can dismiss it without learning anything more than "this might be a real URL."
You can go further and serve a real-looking 404 page when the basic auth header is missing entirely — but the 401 with a WWW-Authenticate is the standard pattern and works fine.
The "don't link to it" rule
Do not put an "Admin" link in the footer. Do not put it in the nav. Do not link to it from the README of your public repo.
You bookmark /admin yourself. Nobody else needs to know it exists. The fewer signals that the route exists, the less attack surface.
When to upgrade
The 50-line pattern stops being enough when:
- You add team members who need different permission levels.
- You need to do CRUD operations on data (editing rows, banning users) and the admin needs to be more than a viewer.
- You need to expose admin functionality to support staff who shouldn't see all data.
When that day comes, build a real auth system for admins (or add admin roles to your existing user system) and migrate. Until then, basic auth and a generated password are enough.
What this site has
Just so I'm not preaching what I don't ship — vibecodersguidetomvp.help doesn't have an admin dashboard yet because I don't have user accounts to track. When I add a survey endpoint and want to see submissions, I'll add a /admin route with this exact pattern.
The whole point is: the simplest thing that gives you the information that changes your decisions. For an MVP, fifty lines and a basic auth password is that thing.
Don't build a control panel. Build a window.