Iurii RoguliaIurii Rogulia
AboutServicesPricingProjectsStackReviewsPhrasesBlog
Contact
Iurii ships.

Iurii Rogulia, IT partner for business & fractional CTO. Professionally building software since 2001.

Think of a number
PricingQuality checklistPrivacy PolicyCookie Policy

Business

TMI Iurii Rogulia
VAT ID: FI29845875
DUNS: 368664211
Lappeenranta, Finland 🇫🇮

[email protected]
Back to projects

Iurii Oversees: HTPBE? — Internal Admin Dashboard

March 15, 2026

Role-gated admin dashboard for the HTPBE? SaaS platform — real-time KPIs, per-user quota tracking, and a zero-dependency bar chart, all server-rendered via React Server Components.

Live demo

Stack

Next.jsReactTypeScript

Libraries

Drizzle ORMNextAuth.js

Databases

Turso

Services

Stripe

Topics

Admin DashboardSaaSAuthArchitecture

Key Results

  • One screen to run the business — conversions, revenue signals, and per-account usage without stitching Stripe and the database by hand
  • Spot at-risk accounts early — live quota progress bars flag users near their limit before they hit it
  • Billing data you can trust — usage counted live from source, so numbers never drift out of sync
  • Locked to the owner alone — a single server-side access check, no way to bypass from the browser
  • Fast, cheap internal tool — zero chart libraries and no client data layer keep it lightweight
HTPBE? — Internal Admin Dashboard

The Business Problem

Once HTPBE? went live with paying customers, operating the platform blind became a real problem. I needed to answer everyday business questions: how many users converted to a paid plan this month? Which accounts are close to their quota limit and likely to upgrade? Did a specific user actually use the product, or just sign up and disappear?

The answers existed, but scattered. The Stripe dashboard held billing, the raw database held usage, and stitching them together on every question was slow and error-prone. There was no single place to see the health of the business — who is active, who is at risk, who is worth a nudge toward a higher plan.

The Solution

I built one internal dashboard that combines everything an operator needs on a single screen: total users and conversion rate up top, a plan-by-plan breakdown, and a per-account drill-down showing usage, quota progress, subscription state, and recent activity. No separate backend, no extra data-fetching library — the pages read straight from the database and render as finished HTML.

Three design decisions drove the value. First, the whole admin area is locked to the owner's account with a server-side check that runs before any data loads — there is no browser-side toggle to bypass. Second, quota usage is always counted live from the underlying activity, never from a stored counter, so the numbers can't quietly drift out of sync and mislead a billing decision. Third, each account carries a colour-coded quota bar (yellow as it fills, red near the limit) so accounts approaching their cap — the natural upgrade candidates — surface at a glance. Stripe customer and subscription links sit right on the page, so investigating a payment issue is one click, not a copy-paste hunt.

Results

The result is a single operational cockpit for the SaaS: who converted, who is active, who is near their limit, and where each account stands in Stripe — answerable in seconds instead of by cross-referencing tools.

MetricValue
Client JS for data0 kB — fully server-rendered
Chart libraryNone — pure JSX, ~40 lines
Quota drift riskEliminated — calculated live from checks table
Queries per page load (overview)4 parallel (users, month checks, all-time checks, subscriptions)
Queries per page load (detail)5 parallel (user, month checks, all-time, daily chart, API keys + recent checks)
Access controlSingle email check, server-side, before any data query executes

What the Dashboard Shows

The overview aggregates KPIs across every account — total users, conversion rate, and live checks this month and all-time — plus a plan breakdown bar (free / starter / growth / pro / enterprise). The main table lists all users newest first with a plan badge, subscription status, live and test usage this month, all-time counts, and a quota progress bar, each row linking to a per-user page.

Each per-user page shows a subscription card (plan, status, monthly quota, current period, overage, and direct Stripe links), the account's API keys with their environment and last-used status, a 30-day activity chart, and the 100 most recent checks with full result and file metadata.

Under the Hood

For the technically curious, here is how the core pieces are built.

Role Guard

Both admin routes are React Server Components under the existing (dashboard) layout group: an overview at app/(dashboard)/dashboard/admin/page.tsx and per-user detail at app/(dashboard)/dashboard/admin/[userId]/page.tsx. The guard runs at the top of each, before any data is fetched:

// app/(dashboard)/dashboard/admin/page.tsx
const session = await auth();
if (session?.user?.email !== "[email protected]") {
  redirect("/dashboard");
}

Next.js redirect() in a Server Component throws immediately, so no data queries execute for unauthorized sessions. There is no client-side conditional rendering to bypass.

Aggregating KPIs in a Single Pass

The overview page runs four parallel queries: all users, monthly checks grouped by user and environment, all-time checks grouped the same way, and current subscriptions. Grouping by environment in SQL (rather than filtering in JavaScript) means the database does the work once:

// app/(dashboard)/dashboard/admin/page.tsx
const monthCheckRows = await db
  .select({
    userId: apiKeys.userId,
    environment: apiKeys.environment,
    total: sql<number>`cast(count(*) as integer)`,
  })
  .from(checks)
  .innerJoin(apiKeys, sql`${checks.apiKeyId} = ${apiKeys.id}`)
  .where(gte(checks.checkDate, monthStart))
  .groupBy(apiKeys.userId, apiKeys.environment);

The result rows are indexed into a Map<userId, { live: number; test: number }> in JavaScript before rendering — one pass, no repeated array searches per table row. The plan breakdown bar is derived from the same allUsers array: group by plan field, count each bucket, compute percentage. No extra query.

Per-User Quota Progress

Quota uses the same dynamic calculation as the public API — live checks this month counted from the checks table, never from a stored counter. This keeps the dashboard consistent with what the user sees in their own dashboard and avoids the class of bugs where a counter drifts out of sync after a failed transaction:

// app/(dashboard)/dashboard/admin/[userId]/page.tsx
const limit = user.requestsPerMonth; // null = unlimited (Enterprise)
const usagePct = limit ? Math.min(Math.round((monthLive / limit) * 100), 100) : 0;
 
// Progress bar colour: red ≥90%, yellow ≥70%, primary otherwise
const barColor = usagePct >= 90 ? "bg-red-500" : usagePct >= 70 ? "bg-yellow-500" : "bg-primary";

Overage (checks beyond the monthly quota) is surfaced separately so I can see accounts that went over before a plan upgrade was processed.

30-Day Activity Chart in Pure JSX

I considered Recharts and Chart.js but rejected both — a single bar chart does not justify a 200+ kB dependency in an internal tool. The chart renders in pure JSX: a flex row of proportionally scaled div elements, each with a CSS height set from the day's count relative to the month's maximum. Days with zero checks still render (height 0), which keeps the x-axis gaps honest:

// app/(dashboard)/dashboard/admin/[userId]/page.tsx
// Fill all 30 days from epoch-day arithmetic so zero-check days are explicit
const todayDay = Math.floor(Date.now() / 1000 / 86400);
const dailyMap = new Map(dailyRows.map((r) => [r.day, r.count]));
const chartDays: { label: string; count: number }[] = [];
 
for (let i = 29; i >= 0; i--) {
  const day = todayDay - i;
  const d = new Date(day * 86400 * 1000);
  chartDays.push({
    label: d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", timeZone: "UTC" }),
    count: dailyMap.get(day) ?? 0,
  });
}
// Rendered chart — no library, pure JSX
<div className="flex h-32 items-end gap-0.5">
  {chartDays.map((day, i) => {
    const pct = maxCount > 0 ? (day.count / maxCount) * 100 : 0;
    return (
      <div
        key={i}
        className="group relative flex flex-1 flex-col items-center justify-end"
        title={`${day.label}: ${day.count}`}
      >
        <div
          className="w-full rounded-t bg-primary/70 transition-all group-hover:bg-primary"
          style={{ height: `${Math.max(pct, day.count > 0 ? 3 : 0)}%` }}
        />
        {day.count > 0 && (
          <div className="pointer-events-none absolute bottom-full hidden whitespace-nowrap rounded bg-black/80 px-2 py-1 text-xs text-white group-hover:block">
            {day.label}: {day.count}
          </div>
        )}
      </div>
    );
  })}
</div>

The Math.max(pct, day.count > 0 ? 3 : 0) guard ensures a bar with one check is still visible even when the month's maximum is in the hundreds.

Users Table and Detail Drill-Down

The main table shows all users sorted newest first: name, email, a plan badge, a subscription status badge, live checks this month with a quota progress bar, test checks this month, all-time live and test counts, and registration date. Each row has a "Details →" link to the per-user page.

The per-user detail page shows:

  • A subscription card with plan, status, quota per month, current period dates, overage count, and direct links to the Stripe customer object and subscription object (opens in Stripe Dashboard)
  • The API keys list: environment badge (live = green, test = yellow), the last four characters of the key, creation date, last-used date, and active/inactive status
  • The 30-day bar chart described above
  • Recent 100 checks via the shared ChecksTable component already used elsewhere in the dashboard: filename, check date, result badge, origin type, file size, client ID, key name with environment badge, and PDF creator/producer metadata

The Stripe links use the customer ID and subscription ID stored in the users table and require no Stripe API call at render time.

Iurii RoguliaAvailable

Need something similar?

I build custom solutions — from APIs to full products. Let's talk about your project.

View all projects

Related projects

vatnode — EU VAT Validation API
vatnode — EU VAT Validation API
January 19, 2026
vatnode — EU VAT Validation API

Developer-first SaaS API for EU VAT validation via VIES with Redis caching, change monitoring, and webhook notifications.

Stack

Next.jsReactTypeScriptHonoNode.jsTurborepoDockerCaddy

Libraries

Drizzle ORMBullMQZodBetter AuthTanStack QueryZustand

Databases

PostgreSQLRedis

Services

StripeResendGoogle Analytics

Topics

SaaSAPIAuthFintechTax/VATMonorepoWebhooksSEOSchema.org
HTPBE? — Has This PDF Been Edited?
HTPBE? — Has This PDF Been Edited?
September 25, 2024
HTPBE? — Has This PDF Been Edited?

SaaS platform for PDF authenticity verification with a public REST API.

Stack

Next.jsReactTypeScript

Libraries

Drizzle ORMZodNextAuth.jspdf-lib

Databases

PostgreSQL

Services

MollieResendGoogle Analytics

Topics

SaaSPDFAPIAuthSecuritySEOSchema.org

Related posts

Real-Time Dashboard in Next.js with TanStack Query + Zustand
April 16, 2026· 12 min
Real-Time Dashboard in Next.js with TanStack Query + Zustand

Real-time Next.js admin dashboard: TanStack Query polling, Zustand for filter state, Sentry error boundaries per panel, and a zero-dependency bar chart.

Stack

Next.jsReactTypeScript

Libraries

TanStack QueryZustandDrizzle ORM

Services

SentryStripe

Topics

Admin DashboardSaaSData VisualizationArchitecture
Next.js SaaS Checklist: Launch Production-Ready in 8 Weeks
January 19, 2026· 17 min
Next.js SaaS Checklist: Launch Production-Ready in 8 Weeks

40+ point production SaaS checklist: auth, Stripe billing, PostgreSQL, rate limiting, email, monitoring, and security — with honest 8-week build estimates.

Stack

Next.jsTypeScript

Libraries

Drizzle ORMBetter AuthBullMQZodReact Email

Databases

PostgreSQLRedis

Services

StripeVercelResendSentry

Topics

SaaSArchitectureAuth