Vite + React + Tailwind blog with a self-hosted post/project/milestone CMS at
/admin/studio. All content — posts, projects, milestones, skills, social
links, drafts — lives in the same Supabase Postgres database used by the
portfolio repo, edited from
the admin dashboard on any device (local or deployed), no git commits
required to publish. Media (images/video) lives in Supabase Storage.
This site is deployed separately from the portfolio (kdbrian.github.io),
as a GitHub Pages project site at kdbrian.github.io/blog. The portfolio
stays a lightweight, read-only front end (skills, education, projects) that
reads the same Supabase data; this repo owns the blog, milestones/activity,
skill playground, and all write-side admin tooling.
src/pages/ BlogList, PostDetail, Projects, ProjectDetail (full case studies — screenshots,
write-up, commit history), Activity, Playground, admin/StudioApp
src/lib/ async fetchers (posts, projects, activity, skills, social, profile, history) against Supabase's PostgREST API
src/components/admin/ Studio editors: PostEditor, ProjectManager, MilestonesManager, SocialLinksManager,
ProfileManager, HistoryManager (education & experience), LinksEditor, TagInput,
SkillPicker, ThemePicker, RichTextEditor
supabase/migrations/ schema: posts, projects, milestones, drafts, skills (+ junction tables), social_links,
profile, history_entries (education & experience)
supabase/functions/ edge functions: auth, publish/delete for posts, projects, milestones, skills, social links,
profile, history entries, drafts CRUD, media upload — all write paths, gated by a custom
password + JWT (auth-guard.ts)
.github/workflows/ builds with Vite, deploys the static SPA to GitHub Pages under /blog/
This repo owns every long-form reading experience: blog posts and full
project case studies (/projects, /projects/:slug — screenshots,
write-up, commit history). The portfolio repo only shows overview cards for
both (a projects grid, a "recent articles" strip) and links out here for
anything more than a summary.
Reads (public pages) call Supabase's REST API directly with the anon key,
gated by RLS (select policies only — see the migrations). Writes go
through edge functions using the service-role key, after requireAuth()
checks a custom JWT signed with JWT_SECRET — this app does not use
Supabase Auth.
Note: the portfolio repo also reads several of these same tables directly
(projects, skills, history entries, social links, profile) for its
read-only display — the fetcher files under src/lib/ are intentionally
duplicated there rather than shared, since the two are now independent
repos against one backend.
npm install
cp .env.example .env.local # fill in the two VITE_ vars below — same
# Supabase project as the portfolio repo
npm run devThis repo's Dockerfile/nginx.conf build and serve the static SPA on
their own — no dependency on the portfolio repo or anything outside this
directory. Because the Vite build uses base: "/blog/" (see
vite.config.ts), the nginx config serves the built app under that same
/blog/ subpath, redirecting / to it, so it behaves the same locally as
it does on GitHub Pages:
docker build -t kdbrian-blog \
--build-arg VITE_SUPABASE_URL=https://<ref>.supabase.co \
--build-arg VITE_SUPABASE_ANON_KEY=<anon-key> .
docker run -p 8081:80 kdbrian-blog # → http://localhost:8081/blog/If you're also running the portfolio locally and want both up against the
same Supabase project at once, there's a docker-compose.yml one level up
(in the parent directory that holds both repo checkouts, not inside either
repo — each service still owns its standalone Dockerfile and
.env.example here, so this repo keeps building correctly entirely on its
own, e.g. in CI). It builds both from a single shared .env. That compose
file is local tooling only and isn't tracked by this repo.
-
GitHub Pages: Settings → Pages → Build and deployment → Source → "GitHub Actions" (not "Deploy from a branch" — that classic mode serves raw source files instead of the Vite build output). The included workflow deploys on every push to your default branch. Because this is a project Pages site (repo name
blog, not<user>.github.io), it's served atkdbrian.github.io/blog—vite.config.ts'sbase: "/blog/"already accounts for the subpath. -
Supabase: this repo owns the backend source going forward. If you haven't already deployed the schema/functions from the portfolio repo (or are pointing at a fresh project), link and push from here:
npx supabase login # or set SUPABASE_ACCESS_TOKEN npx supabase link --project-ref <your-project-ref> npx supabase db push npx supabase functions deploy github-auth publish-blog delete-blog publish-project delete-project \ upload-media publish-milestone delete-milestone publish-skill publish-social-link \ delete-social-link publish-profile publish-history-entry delete-history-entry \ drafts-list drafts-save drafts-delete
No local Supabase install or Docker needed — this only talks to your cloud project (
supabase start/local dev emulation is unrelated and unused). -
GitHub OAuth App (Studio sign-in — GitHub is the only login method, there's no password): create one at github.com/settings/developers → "New OAuth App":
- Homepage URL:
https://kdbrian.github.io/blog - Authorization callback URL:
https://kdbrian.github.io/blog/admin/studio(exact match required — this is hardcoded insrc/lib/github-oauth.ts)
Copy the generated Client ID and Client Secret; you'll need both below.
- Homepage URL:
-
Set the secrets below, then visit
https://kdbrian.github.io/blog/admin/studio.
Supabase Edge Function secrets — set with npx supabase secrets set NAME=value. Write-only: there's no way to read a value back once set, so
save whatever you generate somewhere safe first.
| Secret | Purpose |
|---|---|
GITHUB_CLIENT_ID |
Client ID from the GitHub OAuth App above (also needed client-side, see VITE_GITHUB_CLIENT_ID below). |
GITHUB_CLIENT_SECRET |
Client Secret from the same OAuth App. Never exposed to the client — only this edge function sees it. |
ADMIN_GITHUB_LOGIN |
The one GitHub username allowed to sign in (case-insensitive). Defaults to kdbrian if unset. |
JWT_SECRET |
Signs the session token. Generate: openssl rand -base64 32. |
SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are injected automatically by
the platform — nothing to set for those.
GitHub Actions repo secrets (Settings → Secrets and variables → Actions — public-safe values the client bundle needs at build time):
| Secret | Value |
|---|---|
VITE_SUPABASE_URL |
https://<your-project-ref>.supabase.co — same value used in the portfolio repo |
VITE_SUPABASE_ANON_KEY |
Your project's anon/public key — same value used in the portfolio repo |
VITE_GITHUB_CLIENT_ID |
Client ID from the GitHub OAuth App above. Not secret, but kept alongside the others for consistency. |
VITE_SUPABASE_URL/VITE_SUPABASE_ANON_KEY are required — if either is
missing, the build still succeeds, but the client silently builds with an
empty API base URL, so Studio requests go to your own domain instead of
Supabase and fail with a 405. VITE_GITHUB_CLIENT_ID missing just breaks
the "Sign in with GitHub" button.
Client .env.local (local dev only, gitignored) — same three VITE_
values, see .env.example.
- Page loads blank, console shows a MIME-type error on
main.tsx: Pages source is set to "Deploy from a branch" instead of "GitHub Actions". Fix the setting, then push a commit (an empty one is fine) to trigger a fresh deploy — the setting change alone doesn't retroactively republish. - Studio login (or any write) fails with
405on a request to your own domain instead of Supabase:VITE_SUPABASE_URL/VITE_SUPABASE_ANON_KEYActions secrets aren't set. - "Sign in with GitHub" redirects back with an error, or with no
code/stateat all: the OAuth App's Authorization callback URL doesn't exactly matchhttps://kdbrian.github.io/blog/admin/studio, orVITE_GITHUB_CLIENT_IDwasn't set at build time. - GitHub sign-in succeeds but Studio still says unauthorized (403): the
signed-in GitHub username doesn't match
ADMIN_GITHUB_LOGIN(defaults tokdbrian). - Studio login fails with 401 after a successful GitHub sign-in: edge
functions default to Supabase's own gateway-level JWT verification, which
this app's custom auth doesn't satisfy. Every function needs
verify_jwt = falseinsupabase/config.toml(already configured here) — if you add a new function, add its entry too, then redeploy. - GitHub sign-in works locally against
docker compose: it won't — the OAuth callback URL is hardcoded to the production Studio URL. Everything else (reading posts/projects) works fine locally; sign-in only completes against the deployed site. - "Live from GitHub" activity feed shows "Couldn't reach GitHub right now": the unauthenticated GitHub API allows only 60 requests/hour per IP. Self-clears within the hour.
- Assets/icons 404 or the SPA fallback lands on the wrong page: confirm
vite.config.ts'sbaseandpublic/404.html's redirect both say/blog/— a mismatch here is the usual cause after renaming the repo.
- Skills vs. tags: tags are free-text per post/project; skills are a
separate, curated taxonomy (
skillstable with adate_added) shared across posts/projects/milestones and the portfolio's About page stack badges. The Playground page (/playground) picks a skill and shows a chronological history of everything tagged with it. - Theming: posts/projects/milestones can carry an optional background
(
theme: { type: color|gradient|image, value }). Solid colors get a real luminance check for text contrast; gradients/images (can't be cheaply introspected) always get a dark scrim + light text. - New posts are saved as HTML (TipTap's native output); DOMPurify
sanitizes on render regardless of source. The publish function also strips
<script>tags and inline event handlers server-side as defense-in-depth. - Media is scoped per post/project slug in Supabase Storage
(
blog-images/<slug>/…,projects/<slug>/…), so "also delete media" on the delete dialog removes exactly one item's uploads. - Drafts sync across devices via the
draftstable (RLS blocks all public access — only edge functions using the service-role key can touch it), so starting a draft locally and finishing it on another device works.