Alexander Dean
— — —
2025 · Personal project

Client Hosting Portal

A private, password-protected portal for freelance hosting clients. Live service status, Stripe billing management, and a terminal-style authentication flow built entirely on Next.js 16 and Vercel.

Next.js 16 · TypeScript · Tailwind CSS v4 · Stripe

The problem

Freelance clients reliably ask two questions: “is my site down?” and “when does my hosting renew?”. Answering both required an email thread, a manual uptime check, and a reply. For a small number of clients this was manageable; as the practice grows it becomes noise.

The goal was a single branded space where clients can check service health, manage billing, and get in touch — without needing to contact me for any of it. Private by necessity (not every client needs to know which services are running), so it needed a clean auth layer rather than relying on obscurity.

My role

Solo build. Designed the auth architecture, built the status dashboard component, wired up Stripe Customer Portal, and spent more time than strictly necessary on the login experience — because the first impression of a client portal matters, and most clients will only see that screen once.

— Architecture

Frontend
Framework
Next.js 16 · TypeScript · App Router
Styling
Tailwind CSS v4 · Custom CSS animations
Fonts
Fraunces · Instrument Sans · JetBrains Mono
Components
React Server Components + client islands
Auth & Security
Gate
Next.js proxy — guards /hosting/:path*
Session
httpOnly cookie · 64-char random token
Secrets
Password + token stored as env vars
Login UX
Terminal-style verification animation
Billing & Status
Billing
Stripe Customer Portal (hosted)
Payments
Stripe Checkout → /payment-success
Status
Live dashboard · Supabase backend
Hosting
Vercel · global edge network

— Status dashboard

The live component from the portal itself. Each row expands to show a sparkline of recent checks. Uptime bars show the last 30 days — hover a bar for the exact date and percentage. Stats tick up on scroll using a custom easing counter.

Live preview · actual portal componentOperational

— Your hosting

Live status of the servers running your sites.

Live · — — GMT
  • p50 response
    94ms
    p95 response
    187ms
    SSL expires
    312d
    Last check
    28s ago
    Response time · last 14 checks
  • p50 query
    3ms
    p95 query
    11ms
    Replicas
    3 / 3
    Last check
    15s ago
    Query latency · last 14 checks
  • DNS TTL
    300s
    DNSSEC
    Active
    SSL expires
    312d
    Last check
    60s ago
    DNS resolution · last 14 checks
  • p50 relay
    340ms
    p95 relay
    820ms
    DKIM / SPF
    Pass
    Last check
    45s ago
    Relay latency · last 14 checks
  • Last size
    4.2 GB
    Duration
    8m 14s
    Retention
    30 days
    Next run
    21:46 UTC
    Backup size · last 14 runs (GB)
Avg. response
— —
Datacentres
— —
Last incident
— —
Sites hosted
— —
Data served
— —
Avg. uptime
— —

— Metrics & monitoring

The Supabase backend

A scheduled edge function polls each service endpoint every 60 seconds and writes a row into the service_checks table in Supabase — timestamp, service id, response time, and a status enum.

The dashboard queries the last 30 days of checks per service on each page load — a simple aggregation that produces uptime percentage, p50/p95 response times, and the 30-bar history. No caching layer needed at this scale; Postgres handles it in single-digit milliseconds.

Backups and real-time

The backup script logs file size, duration, and timestamp to Supabase after each run. The dashboard reads the most recent row for backup-specific stats — last size, duration, retention window, and time since last run.

Supabase real-time subscriptions push status changes to connected clients without polling — a NOTIFY on the service_checkstable triggers a subscription event and the dashboard updates live. A client watching during an incident sees the “Operational” indicator flip back the moment the service recovers.

Supabase schema · service_checks table
ColumnTypeNotes
iduuidPrimary key, gen_random_uuid()
service_idtextReferences services table
checked_attimestamptzIndexed for range queries
statustextoperational | degraded | down
response_msintegernull if service unreachable
errortextnull if healthy

— The login experience

A terminal-style verification sequence replaces the form while the password is checked. Steps enter one by one before the sequence starts — so you know what's coming. Each step spins, then resolves. The failure state marks the exact step that failed.

Authentication sequence · mid-verification
RECEIVE_REQUESTOK
PARSE_CREDENTIALSOK
VERIFY_PASSWORD
OPEN_SESSION
Authentication sequence · wrong password
RECEIVE_REQUESTOK
PARSE_CREDENTIALSOK
VERIFY_PASSWORDFAILED
OPEN_SESSION

Each step maps to what the server action does. RECEIVE_REQUEST is the HTTP round-trip; PARSE_CREDENTIALS is formData.get("password"); VERIFY_PASSWORD is the env-var comparison; OPEN_SESSION sets the httpOnly cookie.

— Key decisions

Random token in the cookie, not a static flag
Context

A cookie set to a predictable value like 'authenticated' can be trivially forged by anyone who knows the cookie name and path — a common mistake in simple password gates. Replay attacks and session forgery become trivial.

Outcome

The cookie value is a 64-char random hex token generated once and stored as an env var. The proxy validates the cookie value against the env var on every request — guessing the token is computationally infeasible. Rotating it (changing the env var) immediately invalidates all active sessions globally.

proxy.ts instead of middleware.ts
Context

Next.js 16 deprecated the middleware.ts file and renamed the exported function. Code written against earlier conventions silently fails to intercept requests — there is no error, requests just pass through unguarded.

Outcome

The request intercept layer lives in proxy.ts with an exported proxy function. The matcher config and cookie inspection logic are identical to what middleware.ts would contain — only the filename and export name changed. Heeding the deprecation notice meant the auth gate worked first time.

Payment success route outside the proxy
Context

Stripe redirects to the success URL immediately after a completed checkout. The redirect is a plain GET from Stripe's servers with no auth context. If /payment-success were under /hosting/:path*, every successful payment would land on the login screen before the client could see confirmation.

Outcome

Moved /payment-success to the app root — outside the /hosting/:path* proxy matcher. Clients reach it unauthenticated directly from Stripe. A clear 'back to portal' link takes them into the auth flow once they want to return.

Animation timing coordinated with server response delay
Context

The wrong-password server action adds a deliberate delay before returning. Without careful timing, the error could arrive while the animation is still on an earlier step — the FAILED indicator would land on the wrong row, breaking the illusion.

Outcome

VERIFY_PASSWORD spins between 3450ms and 4290ms. Wrong-password delay is set to 3850ms — mid-spin for that exact step. When the server responds, the animation marks that specific step FAILED and the remaining steps stay dim. Correct password delay (6000ms) lands cleanly after all steps complete at 5590ms.

— Technical depth

The proxy layer

Next.js 16 renamed middleware.ts to proxy.ts with a matching function export rename. The proxy intercepts all requests matching /hosting/:path*. Requests to/hosting/login are passed through unconditionally; everything else requires the auth cookie to match the env-var token. No token or wrong token redirects to the login page.

The cookie is set with httpOnly: true, secure: true, path: "/hosting", and a 30-day maxAge. The path scoping means the browser never sends the cookie to the public-facing routes — it only exists for /hosting requests.

Animation architecture

The login animation runs in two phases managed by separate state variables. Phase one (introRevealed) controls which step rows are visible — they slide in using staggered timeouts before the check sequence begins. Phase two (revealed and done) drives the running → done transitions per step.

A ref (revealedRef) shadows the revealed state to avoid stale closures in the error-handling effect. When the server action returns an error, the effect reads revealedRef.current to find the last step that started — and marks exactly that step as failed. All pending timers are cancelled via a tracked array of timer IDs cleared on each submission.

Uptime bar component

Each service row renders 30 bars representing the last 30 days. Each bar height scales proportionally — a full-height bar is 100% uptime, a short bar is a partial-day incident. Bars animate in with a scaleY keyframe staggered by index, triggered by an IntersectionObserver on the section. Hover states show a tooltip with the exact date and uptime percentage. The detail drawer animates open using CSS grid-template-rows: 0fr → 1fr — no JS height measurement needed.

— Outcomes

0
emails answering 'is my site down'
~ms
login animation coordinates with server
30d
session lifetime on one login
← All case studies