A self-hosted developer portfolio with a database-driven admin panel. All content (name, bio, experience, projects, writing, skills, links) is managed through the admin UI with no code changes required. Built to be forked and made your own.
- Framework — Next.js 14 App Router, TypeScript, deployed on Cloudflare Workers (edge)
- Database — Supabase (Postgres) with RLS, Auth (magic link + TOTP MFA), and Storage
- Styling — CSS custom properties in
globals.css, no component library - Fonts — Space Grotesk + JetBrains Mono
- Tests — Vitest (unit), Playwright (E2E)
- CI/CD — GitHub Actions: lint → unit → smoke → build → deploy
Everything runs within free tiers as of current deployment:
| Service | Plan | Constraint to watch |
|---|---|---|
| Cloudflare Workers | Free | 3 MiB gzip bundle limit. CI enforces this with a size gate on every PR. Adding next/og or other WASM-heavy packages pushes past this; upgrade to Workers Paid (~$5/mo, 10 MiB limit) before adding dynamic OG images back. |
| Supabase | Free | 500 MB database, 1 GB file storage, 50k monthly active users. The keep-alive workflow pings the project to prevent free-tier pauses. |
| GitHub Actions | Free (public repo) | Unlimited minutes for public repos. |
| Cloudflare Web Analytics | Free | Unlimited, no cookies, no GDPR banner needed. |
| Google Search Console | Free | No limits relevant here. |
- Admin portal at
/admin— CRUD for all content, protected by Supabase Auth + user ID allowlist - Configurable accent color, avatar, section visibility, and copy from the admin UI
- Resume PDF generated on-demand from your data
- Custom page view analytics — referrer, country, bot classification, unique visitor approximation; no cookies, no third-party required
- Cloudflare Web Analytics support — enable with one env var for aggregate dashboards (top pages, countries, Core Web Vitals)
- Dynamic sitemap, robots.txt, and
llms.txt— all driven by your domain and feature flags, update automatically when you toggle sections - JSON-LD structured data (Person, WebSite, SoftwareApplication) and Open Graph images generated from your profile data
import_stagingreview queue for importing work data from a second machine
- Create a project at supabase.com
- Copy your project ref, URL, publishable key, and service role key
- Authentication → URL Configuration: set Site URL to your Worker domain from step 2 (e.g.
https://your-worker.workers.dev) and addhttps://your-worker.workers.dev/admin/auth/callbackto Redirect URLs. Also addhttp://localhost:3000/admin/auth/callbackfor local dev. Without this, magic links point to localhost and sign-in fails on the deployed site. - Authentication → URL Configuration → Auth flow type: set to PKCE. The callback route exchanges a
?code=param; Implicit flow puts tokens in the URL fragment and will land on an error page.
- Log in to cloudflare.com and note your Account ID from the Workers & Pages dashboard sidebar
- Create an API token: My Profile → API Tokens → Create Token → Edit Cloudflare Workers template
- Your Worker will be available at
https://portfolio.<account>.workers.devafter first deploy. You can add a custom domain from the Worker's dashboard once it's live.
Copy .env.example to .env and fill in your values:
cp .env.example .envVariables are needed in three places: local dev, CI, and the deployed Worker.
All variables for npm run dev and manual local deploys.
| Variable | Where to find it |
|---|---|
NEXT_PUBLIC_SITE_DOMAIN |
Your domain, e.g. yoursite.dev |
NEXT_PUBLIC_SUPABASE_URL |
Supabase → Project Settings → API |
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY |
Supabase → Project Settings → API |
SUPABASE_SERVICE_ROLE_KEY |
Supabase → Project Settings → API |
SUPABASE_PROJECT_ID |
Your Supabase project ref |
ADMIN_USER_ID |
Set after first login (step 5) |
NEXT_PUBLIC_TURNSTILE_SITE_KEY |
(Optional) Cloudflare Turnstile → Add site — see bot protection |
TURNSTILE_SECRET_KEY |
(Optional) Cloudflare Turnstile → Add site — see bot protection |
NEXT_PUBLIC_GSC_VERIFICATION |
(Optional) Google Search Console → Verify → HTML tag → content value |
Set at repo → Settings → Secrets and variables → Actions.
Secrets (encrypted, for sensitive values):
| Secret | Purpose |
|---|---|
NEXT_PUBLIC_SUPABASE_URL |
Baked into the JS bundle during CI build |
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY |
Baked into the JS bundle during CI build |
NEXT_PUBLIC_TURNSTILE_SITE_KEY |
(Optional) Baked into the bundle; required for the Turnstile widget |
SUPABASE_ACCESS_TOKEN |
Authenticates Supabase CLI for migration jobs (supabase.com → Account → Access Tokens) |
SUPABASE_DB_PASSWORD |
DB password for migration push (Supabase → Settings → Database) |
SUPABASE_PROJECT_ID |
Project ref for supabase link in migration jobs |
CLOUDFLARE_API_TOKEN |
Authenticates wrangler deploy (from step 2) |
CLOUDFLARE_ACCOUNT_ID |
Target Cloudflare account for deploy (from step 2) |
Variables (non-secret, set under the Variables tab):
| Variable | Purpose |
|---|---|
NEXT_PUBLIC_SITE_DOMAIN |
Your domain, baked into the bundle at build time |
WORKER_NAME |
(Optional) Name for the deployed Worker. Defaults to portfolio if unset. |
Server-side-only variables accessed at request time by the deployed Worker. Set these via the Cloudflare dashboard (Workers & Pages → your worker → Settings → Variables → Secrets) or with wrangler secret put <NAME>.
NEXT_PUBLIC_*vars are baked into the bundle at build time and do not need to be set here.
| Secret | Where to find it |
|---|---|
SUPABASE_SERVICE_ROLE_KEY |
Supabase → Project Settings → API |
ADMIN_USER_ID |
Your Supabase auth UUID (set after first login) |
TURNSTILE_SECRET_KEY |
(Optional) Cloudflare Turnstile → Add site — see bot protection |
Adds a bot challenge to the admin login form, blocking scripted magic-link requests before they reach Supabase. Optional: the login works without it, with Supabase's own rate limiting as the fallback.
- Go to dash.cloudflare.com → Turnstile → Add site
- Choose Managed mode
- Copy the Site Key →
NEXT_PUBLIC_TURNSTILE_SITE_KEY(add to.env, GitHub Actions secrets, and.dev.varsfor local Worker preview) - Copy the Secret Key →
TURNSTILE_SECRET_KEY(add to.env,.dev.vars, and Cloudflare Worker runtime secrets)
Both keys must be set for Turnstile to activate. If either is missing, the widget is hidden and login works normally.
npx supabase login
npx supabase link --project-ref <your-project-ref>
npx supabase db pushThis applies all migrations in supabase/migrations/ in order, including the default bot signature list and a placeholder profile row so the site renders on first deploy.
Sign-in only works for existing users — new accounts can't be created from the login page. You need to create your account first, then configure the allowlist before you can reach /admin.
- In the Supabase dashboard → Authentication → Users → Invite user, enter your email. This creates your account.
- Copy your user UUID from the Users table — you'll need it before clicking any link.
- Set
ADMIN_USER_ID=<that-uuid>in your Cloudflare Worker secrets (see step 3) and redeploy via CI ornpm run deploy. - Once the redeploy is live, go to
/admin/loginand request a fresh magic link. (The invite link from step 1 may have expired or routed to the wrong URL; always use a fresh link from the login page.) - (Optional) Enable TOTP MFA in Supabase Auth → Configuration → Multi-factor authentication for a second factor.
Go to /admin and fill out your profile — name, bio, tagline, social links, experience, projects, and skills. The site renders entirely from this data.
Everything below is generated from your profile data and content. No code changes required for a fork.
| What | Where | Updates when |
|---|---|---|
/sitemap.xml |
Dynamic — includes only enabled pages + live project/post slugs with real lastModified |
Profile flags change (≤1 hr), new content published |
/robots.txt |
Disallows /admin, /api, disabled sections. Allows AI search bots (ChatGPT, Perplexity, Claude search). |
Profile flags change (immediate) |
/llms.txt |
AI-readable content map — identity, projects, writing, skills, experience. Guides ChatGPT/Perplexity/Claude to your best pages. | Content changes (≤1 hr) |
| Open Graph + Twitter card | On every public page — title, description, image | Profile data changes |
JSON-LD Person + WebSite |
Root layout — powers Google Knowledge Panel eligibility | Profile data changes |
JSON-LD SoftwareApplication |
Each project detail page | Per project |
| OG images | Currently disabled — next/og pushes the Worker bundle past Cloudflare's free plan 3 MiB limit. See Running costs above for the upgrade path. |
— |
Feature flags are consistent across all SEO surfaces. Disabling writing or projects in the admin UI automatically removes those sections from the sitemap, robots.txt, and llms.txt.
Search Console tells Google your site exists, shows you what search terms surface it, and flags indexing errors.
- Go to search.google.com/search-console and add a Domain property (not URL prefix; Domain covers all subdomains and both http/https)
- Verify ownership via DNS TXT record (simplest if your domain is on Cloudflare):
- Copy the
google-site-verification=...TXT record value from Search Console - In Cloudflare dashboard → your domain → DNS → Records → Add record
- Type:
TXT, Name:@, Content: the fullgoogle-site-verification=...string, TTL: Auto - Click Verify in Search Console — usually confirms within a minute
- Copy the
- Submit your sitemap: Sitemaps → Add a new sitemap → enter
sitemap.xml→ Submit - Request indexing for your homepage: URL Inspection → enter your domain → Request Indexing
Alternative — HTML meta tag verification: If you'd rather not touch DNS, set
NEXT_PUBLIC_GSC_VERIFICATIONin your env to the token value from<meta name="google-site-verification" content="TOKEN">(just the token, not the full tag). Add this to your GitHub Actions secrets and Cloudflare Worker secrets too so the tag appears in production.
- Go to bing.com/webmasters and sign in with a Microsoft account
- Add your site and verify — choose XML file or DNS (the same Google TXT record works here too, or import from Search Console automatically)
- Submit sitemap: Sitemaps → Submit sitemap →
https://yourdomain.com/sitemap.xml
You're already on Cloudflare. Web Analytics gives you top pages, countries, referrers, devices, and Core Web Vitals with no cookies and no GDPR banner required.
- Go to dash.cloudflare.com → Workers & Pages → select your Worker → Web Analytics tab (or go to Analytics & Logs → Web Analytics in the sidebar)
- Click Enable — Cloudflare generates a beacon token for your site
- Copy the token (a ~32-character hex string from the script tag it shows you)
- Add it to your env:
Also add to GitHub Actions secrets (for CI builds) and Cloudflare Worker env vars
NEXT_PUBLIC_CF_BEACON_TOKEN=your_token_here
- Redeploy — the beacon script is injected automatically when the token is set
The script only loads when NEXT_PUBLIC_CF_BEACON_TOKEN is present, so local dev and staging environments without the token stay clean.
The site logs every public page hit to the page_views Supabase table automatically. Cloudflare Web Analytics gives you aggregates; this gives you raw, queryable data:
- Which pages get traffic — see if a specific project page is getting hits
- Where traffic comes from —
referrershows if Google, LinkedIn, or someone's GitHub README is sending visitors - Geography —
countryfrom Cloudflare's edge headers, no geolocation service needed - Unique visitor approximation —
ip_hash(SHA-256, never raw IP) lets you estimate distinct visitors without storing personal data - Bot visibility —
is_botandbot_nametell you which crawlers are hitting you (Googlebot, GPTBot, etc.) and how often, separate from human traffic
View raw data in Supabase Studio → your project → Table Editor → page_views, or query it directly for top pages, traffic by country, daily trend, or bot vs. human ratio.
| Variable | Where to find it | Required |
|---|---|---|
NEXT_PUBLIC_SITE_DOMAIN |
Your domain, e.g. yoursite.dev |
Yes |
NEXT_PUBLIC_CF_BEACON_TOKEN |
Cloudflare dashboard → Web Analytics → your site → token | No |
NEXT_PUBLIC_GSC_VERIFICATION |
Google Search Console → Verify → HTML tag → token value only | No |
The CI badges at the top of this file point to the original repo. Replace Zaqttack/portfolio in the three badge URLs with your GitHub username and repo name to track your own deployments.
The magic link email is sending users to http://localhost:3000. Fix: Supabase dashboard → Authentication → URL Configuration → set Site URL to your deployed domain and add the callback URL to Redirect URLs (see setup step 1).
The URL hitting /admin/auth/callback has no ?code= parameter. Supabase is using Implicit flow instead of PKCE. Fix: Supabase dashboard → Authentication → URL Configuration → Auth flow type → set to PKCE, then request a fresh magic link from /admin/login.
The code exchange failed. Most common causes:
- Stale link — the invite or magic link was clicked before redirect URLs were configured correctly (it routed to localhost and the code was consumed). Request a fresh magic link from
/admin/login. - User doesn't exist — sign-in is locked to existing users. Create your account via Authentication → Users → Invite user in the Supabase dashboard first.
ADMIN_USER_ID isn't set or doesn't match your Supabase user UUID. The middleware checks the session user ID against this value. Set it in your Cloudflare Worker secrets and redeploy.
npm install
npm run dev # Next.js dev server, reads from .envTo run the actual Cloudflare Worker locally (closer to production):
cp .dev.vars.example .dev.vars
# fill in .dev.vars, then:
npm run preview # builds the Worker and runs it via wrangler dev.dev.vars is the Wrangler equivalent of .env for runtime secrets. The NEXT_PUBLIC_* vars still come from .env since they're baked at build time.
npm run lint # Biome lint check
npm run format # Biome format (writes)
npm test # Vitest unit tests
npm run test:e2e # Playwright E2EMIT — see LICENSE.
Built by Zaquariah Holland