Skip to content

Zaqttack/portfolio

Repository files navigation

Portfolio

Test Deploy Keep Supabase Alive

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.

Stack

  • 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

Running costs

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.

Features

  • 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_staging review queue for importing work data from a second machine

Setup

1. Supabase

  1. Create a project at supabase.com
  2. Copy your project ref, URL, publishable key, and service role key
  3. Authentication → URL Configuration: set Site URL to your Worker domain from step 2 (e.g. https://your-worker.workers.dev) and add https://your-worker.workers.dev/admin/auth/callback to Redirect URLs. Also add http://localhost:3000/admin/auth/callback for local dev. Without this, magic links point to localhost and sign-in fails on the deployed site.
  4. 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.

2. Cloudflare Workers

  1. Log in to cloudflare.com and note your Account ID from the Workers & Pages dashboard sidebar
  2. Create an API token: My Profile → API Tokens → Create Token → Edit Cloudflare Workers template
  3. Your Worker will be available at https://portfolio.<account>.workers.dev after first deploy. You can add a custom domain from the Worker's dashboard once it's live.

3. Environment variables

Copy .env.example to .env and fill in your values:

cp .env.example .env

Variables are needed in three places: local dev, CI, and the deployed Worker.

Local .env

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

GitHub Actions — secrets and variables

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.

Cloudflare Worker — runtime secrets

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

Bot protection (optional — Cloudflare Turnstile)

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.

  1. Go to dash.cloudflare.comTurnstile → Add site
  2. Choose Managed mode
  3. Copy the Site KeyNEXT_PUBLIC_TURNSTILE_SITE_KEY (add to .env, GitHub Actions secrets, and .dev.vars for local Worker preview)
  4. Copy the Secret KeyTURNSTILE_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.

4. Run migrations

npx supabase login
npx supabase link --project-ref <your-project-ref>
npx supabase db push

This 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.

5. First login and admin user

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.

  1. In the Supabase dashboard → Authentication → Users → Invite user, enter your email. This creates your account.
  2. Copy your user UUID from the Users table — you'll need it before clicking any link.
  3. Set ADMIN_USER_ID=<that-uuid> in your Cloudflare Worker secrets (see step 3) and redeploy via CI or npm run deploy.
  4. Once the redeploy is live, go to /admin/login and 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.)
  5. (Optional) Enable TOTP MFA in Supabase Auth → Configuration → Multi-factor authentication for a second factor.

6. Fill in your content

Go to /admin and fill out your profile — name, bio, tagline, social links, experience, projects, and skills. The site renders entirely from this data.

7. SEO and analytics setup

What's automatic

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.

Google Search Console

Search Console tells Google your site exists, shows you what search terms surface it, and flags indexing errors.

  1. Go to search.google.com/search-console and add a Domain property (not URL prefix; Domain covers all subdomains and both http/https)
  2. 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 full google-site-verification=... string, TTL: Auto
    • Click Verify in Search Console — usually confirms within a minute
  3. Submit your sitemap: Sitemaps → Add a new sitemap → enter sitemap.xml → Submit
  4. 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_VERIFICATION in 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.

Bing Webmaster Tools (optional, feeds Microsoft Copilot)

  1. Go to bing.com/webmasters and sign in with a Microsoft account
  2. Add your site and verify — choose XML file or DNS (the same Google TXT record works here too, or import from Search Console automatically)
  3. Submit sitemap: Sitemaps → Submit sitemaphttps://yourdomain.com/sitemap.xml

Cloudflare Web Analytics (recommended — free, no cookies)

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.

  1. Go to dash.cloudflare.comWorkers & Pages → select your Worker → Web Analytics tab (or go to Analytics & Logs → Web Analytics in the sidebar)
  2. Click Enable — Cloudflare generates a beacon token for your site
  3. Copy the token (a ~32-character hex string from the script tag it shows you)
  4. Add it to your env:
    NEXT_PUBLIC_CF_BEACON_TOKEN=your_token_here
    Also add to GitHub Actions secrets (for CI builds) and Cloudflare Worker env vars
  5. 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.

Custom page view analytics (built-in, no setup required)

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 fromreferrer shows if Google, LinkedIn, or someone's GitHub README is sending visitors
  • Geographycountry from Cloudflare's edge headers, no geolocation service needed
  • Unique visitor approximationip_hash (SHA-256, never raw IP) lets you estimate distinct visitors without storing personal data
  • Bot visibilityis_bot and bot_name tell 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.

Analytics env vars

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

8. Update the README badges

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.

Troubleshooting

Magic link redirects to localhost

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).

Magic link callback lands on ?error=no_code

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.

Magic link callback lands on ?error=auth_error

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 redirects back to /admin/login after successful sign-in

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.

Local development

npm install
npm run dev       # Next.js dev server, reads from .env

To 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 E2E

License

MIT — see LICENSE.


Built by Zaquariah Holland

About

Personal portfolio developed with NextJS and CloudFlare

Resources

License

Stars

1 star

Watchers

1 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors