Meta · Self-Initiated · 2026

Most portfolios describe work.
This one is work.

A single HTML file. A Supabase backend. An Edge Function, two webhooks, and an email notification pipeline. Everything a real SaaS product has — built by a content designer, without an engineering team.

My role Thinker · Writer · Coder · Tester · Deployer
Stack HTML · CSS · JavaScript · Deno · Supabase · Resend
Timeline May 2026
Status Live · deployed from GitHub

What this project delivered

0 External templates, CMSs, or platforms used. Everything is owned code.
3 Supabase tables capturing visitor intent, access requests, and contact messages.
<2s Email notification latency from form submission to inbox via Resend + Edge Function.
Password changes with zero code edits. One row update in Supabase dashboard.
01

Most portfolios describe work. This one is work.

The brief I wrote for myself had one constraint: it had to do everything a real product does. Gate sensitive content. Capture visitor intent. Notify me when someone reaches out. Measure who's reading and how far they get. Not with third-party plugins — with infrastructure I own and understand.

That meant building a backend. Not because it was technically required for a portfolio, but because a content designer who can wire up a production-grade notification pipeline is a different kind of hire than one who cannot.

The portfolio became the proof of the thing it was claiming.

The design principle behind every technical decision

If someone with engineering experience opened the browser console or inspected the network requests, would they find something worth respecting? That was the bar. Every feature was built to that standard.

Three audiences, three different needs

Hiring managers need to see outcomes in under 10 seconds. Design leads need to see process depth. Engineers and technical peers need to see that the infrastructure is real — not simulated, not faked with a mailto link. Each section of the portfolio was written and built with one of these audiences in mind at a time.


02

What the system actually does.

The portfolio is a single index.html file. No build step, no framework, no server. But it connects to a real database, calls a serverless function, and triggers email notifications — the same infrastructure pattern used in production SaaS applications.

Full data flow: visitor to my inbox

// Visitor action → database → edge function → email
Visitor lands
on portfolio
index.html
Clicks locked
case study
openGate()
Enters email
in gate modal
requestAccess()
Supabase insert
gate_requests
REST API
Webhook fires
notify-rishi fn
DB webhook
Edge Function
calls Resend
Deno runtime
Email lands
in my inbox
<2 seconds

The same flow, contact form path

// Contact form submission path
Visitor fills
contact form
index.html
handleSubmit()
fires
async function
Insert to
contact_messages
Supabase table
notify_contact_
message webhook
INSERT trigger
notify-rishi
Edge Function
Deno · TypeScript
Email with name,
message, time
Resend API

Session logging runs a parallel track

Alongside the Supabase pipeline, a session fingerprinting system captures every real visit. This uses a separate channel — Google Apps Script — so visitor analytics and form notifications don't share infrastructure. Both run silently.

// Session logging (parallel, not blocking)
Page load
SESSION object
IntersectionObserver
tracks sections
scroll events
3s timeout
fires logSession()
engaged event
fetch POST
no-cors mode
Apps Script URL
Google Sheet
row appended
Sessions tab

03

Every tool, and why it was chosen.

Frontend
Vanilla HTML/CSS/JS
No framework, no build step. Single file deploys instantly, loads in under 1 second, works offline. Ownership over every line.
index.html
Database
Supabase (PostgreSQL)
Managed Postgres with a REST API and Row Level Security. Free tier handles this load. Dashboard gives a real-time view of every submission without logging in to code.
3 tables
Serverless runtime
Supabase Edge Functions
TypeScript running on Deno at the edge. No server to maintain. Triggered by database webhooks, not by HTTP calls from the browser — so no API key exposure.
notify-rishi
Email delivery
Resend
Developer-first email API. Free tier: 3,000 emails/month. One API call from the Edge Function sends a formatted email with full submission context.
onboarding@resend.dev
Event triggers
Database Webhooks
Supabase fires a POST to the Edge Function URL on every INSERT to the watched tables. The service role key in the Authorization header gives the webhook permission to call the function.
2 webhooks
Analytics
Google Apps Script
Session fingerprinting logs to a private Google Sheet via a deployed web app. Captures timezone, referrer, viewport, sections viewed, and time on site. Runs alongside Supabase without replacing it.
doPost endpoint
Version control
GitHub (SSH)
Repo at rishi2807/rishi-portfolio. SSH key auth configured on the Cursor machine. Pushes to main branch. Supabase CLI linked to the project via personal access token.
main branch
CLI tooling
Supabase CLI
Installed via Homebrew. Used to link the project, set secrets, and deploy Edge Functions. Docker is not required — deployment works via the managed cloud runtime without it.
brew install supabase
Password management
Supabase Table Editor
The gate password lives in the access_config table as a key-value row. Changing it requires editing one cell in the dashboard. No code deployment, no file edit, no commit.
access_config

04

Three tables. One clear job each.

The schema was designed to be queryable and readable from the Supabase dashboard without writing SQL. Each table captures a distinct visitor intent — configuration, access requests, and contact messages — and Row Level Security controls what anonymous visitors can do.

supabase · SQL editor · schema setup SQL
-- Table 1: Gate password. Edit value to change password — no code deploy needed. create table access_config ( id bigint primary key generated always as identity, key text unique not null, value text not null ); insert into access_config (key, value) values ('gate_password', 'my-set-password-here');   -- Table 2: Every email access request, with which case study they wanted. create table gate_requests ( id bigint primary key generated always as identity, email text not null, target text, created_at timestamptz default now() );   -- Table 3: Contact form submissions — name, email, message. create table contact_messages ( id bigint primary key generated always as identity, name text, email text, message text, created_at timestamptz default now() );   -- RLS: anonymous visitors can read the password, insert requests and messages. alter table access_config enable row level security; alter table gate_requests enable row level security; alter table contact_messages enable row level security;   create policy "public read access_config" on access_config for select using (true); create policy "public insert gate_requests" on gate_requests for insert with check (true); create policy "public insert contact_messages" on contact_messages for insert with check (true);
Why RLS matters here

Row Level Security means the anon key (which is visible in the browser source) can only do what the policy allows. Anonymous visitors can read the password row and insert new records — they cannot read other people's submissions, delete anything, or update the password. The service role key (used only in webhooks, never in the browser) bypasses RLS entirely.


05

The notification engine.

A single Edge Function handles both notification types. It reads the table field from the webhook payload to decide which email template to send. One function, two webhooks, zero duplication.

supabase/functions/notify-rishi/index.ts TypeScript · Deno
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'   serve(async (req) => { const payload = await req.json() const { table, record } = payload // webhook sends table name + inserted row   let subject = '', html = ''   if (table === 'gate_requests') { subject = 'Portfolio access request' html = `<h2>New access request</h2> <p><strong>Email:</strong> ${record.email}</p> <p><strong>Requested:</strong> ${record.target}</p>` } else if (table === 'contact_messages') { subject = 'New portfolio contact message' html = `<h2>New contact form submission</h2> <p><strong>Name:</strong> ${record.name}</p> <p><strong>Message:</strong> ${record.message}</p>` }   await fetch('https://api.resend.com/emails', { method: 'POST', headers: { 'Authorization': `Bearer ${Deno.env.get('RESEND_API_KEY')}` }, body: JSON.stringify({ from: 'Portfolio <onboarding@resend.dev>', to: 'rishi.g.sharma@gmail.com', subject, html }) }) })

Why the API key is safe here

Browser (public)
The anon key lives in index.html and is visible in source. It can only do what RLS policies allow: read one row, insert into two tables.
Database webhook (server-side)
The service role key is only in the webhook's Authorization header inside Supabase's own infrastructure. Never in the browser, never in version control.
Edge Function (server-side)
The Resend API key is stored as a Supabase secret: supabase secrets set RESEND_API_KEY=.... Accessed via Deno.env.get() at runtime. Never in source code.
Result
Three layers of credentials. Each lives at the appropriate trust level. The most sensitive key never touches the client.

Deploy command

Cursor terminal Shell
# Set the Resend secret in Supabase's secret store supabase secrets set RESEND_API_KEY=re_xxxxxxxxxx   # Deploy the function — --no-verify-jwt means the webhook can call it # without a user JWT. Docker not required — runs on managed cloud runtime. supabase functions deploy notify-rishi --no-verify-jwt   # Output WARNING: Docker is not running Deployed Functions on project aoqiidxcyjppenvbohmz: notify-rishi
Docker warning — why it doesn't matter

Supabase CLI shows a Docker warning because local development typically uses Docker to emulate the Supabase stack. For deployment only — which is all we needed — it communicates directly with the Supabase cloud API. The warning is noise. The function deployed successfully.


06

How locked case studies work.

Four case studies are gated: Workflow Hub, Carina Components, Phoenix Onboarding, SPP Reporting, and Search AI. Visitors can unlock with a password, or request access by email. Both paths feed into the notification pipeline.

Password check flow

// checkPassword() — async Supabase lookup
Visitor enters
password
gate-pw input
checkPassword()
fires
async function
SELECT from
access_config
key='gate_password'
Compare
input vs DB value
client-side
Unlock all
locked rows
remove .locked
Why this is better than a hardcoded password

The previous version had const CORRECT_PASSWORD = 'rishi2025' directly in the HTML. Anyone who viewed source had the password. More importantly, changing it required editing code, committing, and redeploying. Now: open Supabase dashboard → Table Editor → access_config → edit the value cell. No deployment. No git commit. Done in 10 seconds.

What the password check looks like in JS

index.html · checkPassword() JavaScript
async function checkPassword() { const val = document.getElementById('gate-pw').value.trim() if (!val) return   // Fetch the current password from Supabase — not hardcoded anywhere const { data, error } = await _sbc .from('access_config') .select('value') .eq('key', 'gate_password') .single()   const correct = !error && data && data.value === val   if (correct) { gateUnlocked = true closeGate() // Remove .locked class from all gated rows — reveals blurred metrics document.querySelectorAll('.prow.locked').forEach(r => r.classList.remove('locked')) // Redirect to the case study URL they were trying to access if (pendingTarget) window.location.href = pendingTarget } }

07

The copy is as deliberate as the code.

Every word on the portfolio was written with the same process I apply to product copy: audience first, outcome second, copy last. Here are the decisions that took the most thinking.

Decision What was considered What shipped and why
Hero headline "Content that makes products click" · "UX content that ships, scales, sticks" "I design content. I build tools. I measure both." Three declarative sentences. Each adds information the previous one didn't. No tagline to decode.
Gate copy "This content is private" · "NDA-protected work" "This case study is gated." Direct. Honest. Signals the work is worth protecting without sounding defensive.
Gate lock indicator Red dot (danger signal) · lock icon Blue dot ("Authorized viewers only"). Shifts the frame from restriction to exclusivity. Visitors feel qualified, not blocked.
Portfolio layout Card grid (3 col) · Masonry · Tabs by category List/table rows. Fastest to scan. Each row shows title, company, impact, and CTA in one horizontal pass. No layout metaphor — just signal.
Stats in hero Bullet list of achievements · Text bio 4-stat horizontal card row. Numbers are faster than sentences. Each stat has a source context as the label, not just the number.
Ticker banner Static announcement bar · No banner "Built this myself. The design, the code, the copy. If you have feedback, I'm all ears." Sentence case. Conversational. Signals the voice before they've read a word of the portfolio.
Dark mode toggle label "Toggle theme" · sun/moon icons only "Dark" / "Light" text + icon. Accessible. Explicit. Matches the direct tone of the rest of the site.
Reckless years section Hiding pre-2017 work entirely · Standard timeline entry Collapsible "notebook page" with hand-drawn aesthetic. The chaos before the craft — honest, human, and memorable. Hides on first load for hiring managers, visible for anyone curious enough to expand it.
Voice rules enforced across every section

No em dashes in prose. No AI tell-tale phrases (seamlessly, transformative, ensure, leveraging). No <strong> as a label prefix. Sentence case everywhere. Declarative, direct sentences. These aren't style preferences — they're the same rules applied to the products I design for.


08

Lighthouse scores for a single HTML file.

The portfolio was audited on Slow 4G mobile — the harshest Lighthouse condition — using an emulated Moto G Power. These are the scores without any build tooling, bundler, or CDN optimisation layer.

94
Performance
FCP 2.5s · LCP 2.5s · CLS 0 · TBT 0ms
92
Accessibility
ARIA roles · contrast ratio · link purpose
81
Best Practices
Security headers · deprecated API · CSP
100
SEO
Meta · structure · crawlability · links
Note on the performance score

Lighthouse flagged Chrome extensions as affecting the run. The real performance score on a clean profile is 96–97. CLS of 0 and TBT of 0ms are the metrics that matter most for real users — both are perfect.

Three issues found. Three fixes applied.

The audit surfaced three actionable issues. Each was diagnosed, understood, and fixed — not suppressed or deferred.

Accessibility
ARIA role children missing
The portfolio grid used role="list" without role="listitem" on child rows. Screen readers couldn't identify the list structure. Fixed by adding role="listitem" to all 15 portfolio row elements.
Best Practices
Deprecated global event API
The switchTab() function relied on the global event object — a deprecated browser API flagged by Lighthouse. Fixed by passing this as a parameter from the onclick handler instead.
Best Practices
Missing security headers
CSP, HSTS, COOP, XFO, and Trusted Types headers were absent. These are set at the CDN level, not in HTML. Fixed by adding a _headers file in the project root — Netlify injects these on every response without touching index.html.

SEO 100 and what it connects to

A perfect SEO score covers the technical baseline: crawlable structure, valid meta description, proper heading hierarchy, descriptive link text, and mobile viewport. This site scores 100 on all of them.

SEO is the floor. AEO — Answer Engine Optimisation — is the next layer. Where SEO is about appearing in search results, AEO is about being the answer that ChatGPT, Gemini, or Perplexity cites. That requires a different writing approach: direct factual prose, unambiguous statements, clear question-and-answer structure, and content that an LLM can extract and attribute with confidence.

This portfolio is structured for both. Every section opens with a declarative statement. The meta description answers the query a recruiter would run. The About section is written as a factual profile, not a narrative. These are content design decisions that happen to align with how answer engines parse and cite content.

Why this matters beyond the score

A content designer who can read a Lighthouse report, identify the root cause of each flag, and apply the right fix — at the HTML layer, the JS layer, and the CDN layer — is operating across a wider surface than most content roles require. These aren't developer tasks. They're content infrastructure tasks, and they're part of what it means to own a product end to end.


09

What this project taught me.

What worked

Shipping the measurement infrastructure before traffic. The Supabase backend and notification pipeline was built before the site was publicly deployed. Every visitor since launch has been trackable.

Writing before building. Every content decision — headline, gate copy, stat labels, nav labels — was made as a deliberate choice, not filled in at the end. The site sounds like it was designed, not assembled.

What I'd do differently

Set up a staging environment. Cursor's AI agent made changes to the gate HTML that weren't explicitly requested — a staging branch would have caught this before it affected the live file.

Write a more explicit Cursor prompt before letting the agent near the gate system. Vague instructions produced a modified UI that needed manual repair. Specificity matters in prompts the same way it matters in product copy.


10

What's being built next.

Phase 01
Backend infrastructure
✓ Complete
  • Supabase project + 3 tables
  • RLS policies
  • Edge Function (notify-rishi)
  • Resend email integration
  • Database webhooks × 2
  • GitHub SSH + CLI setup
Phase 02
Public deployment
✓ Complete
  • Netlify + GitHub Pages deploy ✓
  • Custom domain setup ✓
  • Schema markup (Person entity) ✓
  • AEO meta structure ✓
  • GA4 event tracking ✓
  • Blog (/writing) route — in progress
Phase 03
Intelligence layer
Planned
  • Visitor intent scoring in Supabase
  • Gate request follow-up automation
  • Blog post markdown pipeline
  • Substack newsletter integration
  • Case study view tracking
1
HTML file. No framework, no CMS, no build step.
3
Supabase tables capturing intent across every visitor touchpoint.
2
Webhook-triggered notification pipelines running in production.
0
Engineering support needed. Every line written, deployed, and debugged alone.