My desire to practice my skills and share my acquired knowledge fuels my endeavors.
Loved the project? Please visit the website.
v2 (Astro Γ Bun) runs on karhdo.dev from main Β· v1 (Next.js Γ pnpm) stays on v1.karhdo.dev from the v1 branch.
v2 is a rebuild of the v1 Next.js site, which lives on the v1 branch. It keeps every v1 URL and the existing Postgres data.
| Area | Choice |
|---|---|
| Runtime tooling | Bun 1.3 (package manager, scripts, bun test); Vercel Functions run on Node 24 |
| Framework | Astro 7, TypeScript strict, static by default with a few on-demand routes |
| Interactivity | Vanilla <script> modules. React 19 only for the Giscus comments island and the lazy βK palette |
| Styling | Tailwind CSS v4 (@tailwindcss/vite), Tokyonight Day / Night tokens, CSS-first motion |
| Content | MDX content collections, explicit unified processor, Expressive Code |
| Data | Drizzle ORM + postgres.js on Neon (existing v1 stats table) |
| Search | Pagefind index built after astro build, opened from a βK palette (cmdk) |
| Social images | Satori + resvg, rendered at build time (/og/*.png) |
| Tooling | Biome (lint + format), lefthook pre-commit, GitHub Actions CI |
| Hosting | Vercel via @astrojs/vercel |
- Bento homepage: intro with typed bios, latest post, Spotify now playing (with progress), GitHub activity heatmap (with a contribution-eating snake), blog stats with a 30-day views chart, Token burn (personal Claude Code usage), Daily stack marquee, selected projects, popular tags.
/about: awhoamiterminal card (live local time and uptime) above the author story./career: stats, a year ruler, the career as agit log --graphwith role chapters and per-role stack diffs.- Site-wide snowfall (vanilla canvas at 30 fps, fewer flakes on phones, persists across page navigations, paused in hidden tabs and during transitions, off under reduced motion) over a frost-tinted tilted grid.
- Blog with tag chips, pagination, per-tag pages and RSS feeds (
/feed.xml,/tags/{tag}/feed.xml), sitemap and robots. - Post pages: sticky TOC with scrollspy, reading progress, code blocks with filename tabs, language badge, copy and line markers, callouts and GitHub alerts, image zoom, Twemoji, views and reactions, Giscus comments, newsletter signup (Buttondown).
- Flash-free light/dark/system theme on a
data-themeattribute, view transitions, reduced-motion aware animations. - βK command palette over a Pagefind index, loaded only on first open.
- Footer Neovim-style statusline (build-time repo stars and commit, live Ho Chi Minh City clock) and a v1/v2 version switcher.
Requirements: Bun 1.3.14 (packageManager in package.json) and Node β₯ 22.12 (Astro 7). Docker is only needed for a local Postgres.
bun install # also installs the lefthook pre-commit hook
cp .env.example .env.local # every variable is optional
bun dev # http://localhost:4321Without any env vars the site builds and runs: each integration shows its empty state.
| Command | What it does |
|---|---|
bun dev |
Astro dev server, including the dev-only /dev/* preview pages |
bun test |
Unit tests for the pure modules (*.test.ts next to the code) |
bun run check |
astro check (types) + biome check . |
bunx biome check --write |
Fix lint and formatting |
bun run lint:palette |
Fails on any colour literal in src/ outside the Tokyonight token files |
bun run build |
Production build into .vercel/output (OG images and Pagefind index included) |
bun run db:pull |
Drizzle introspection only (see Database) |
astro preview isn't supported by @astrojs/vercel, so bun run preview only prints a pointer here and exits. To check a build locally, serve .vercel/output/static with a static server, use bunx vercel dev, or deploy a preview. The on-demand routes (/api/*, /projects, /newsletter) need bun dev.
Views, reactions and the blog stats card need a database. The stats table is owned by the v1 Prisma migrations, so a local copy is created from those SQL files on the v1 branch:
docker compose up -d postgres
export POSTGRES_URL=postgres://postgres:postgres@localhost:5432/karhdo_blog
git fetch origin v1
for m in 20230921142214_init_db 20241227070913_create_tbl_stats; do
git show "origin/v1:prisma/migrations/$m/migration.sql" | psql "$POSTGRES_URL" -v ON_ERROR_STOP=1
done
psql "$POSTGRES_URL" -v ON_ERROR_STOP=1 -f db/manual-migrations/0001_create_stats_daily.sqlThen put the same POSTGRES_URL in .env. SSL is turned off automatically for localhost.
βββ astro.config.mjs # adapter, integrations, unified markdown processor, astro:env schema
βββ ec.config.mjs # Expressive Code themes (tokyo-night + converted Tokyonight Day)
βββ drizzle.config.ts # introspection only (never push/migrate)
βββ db/manual-migrations/ # reviewed, additive SQL run by hand with psql
βββ scripts/ # build-info.mjs (footer facts), lint-palette.ts, convert-tmtheme.ts
βββ public/static/ # favicons, images, resume.pdf, vendored Twemoji SVGs
βββ src/
βββ pages/ # routes; api/*, projects.astro and newsletter.astro are on-demand, the rest is prerendered
βββ layouts/ # BaseLayout, PageLayout, ListLayout, PostLayout
βββ components/ # by feature: home, blog, header, footer, search, seo, ui, mdx, islands β¦
βββ content/ # blog/*.mdx, authors/*.mdx (content.config.ts defines the collections)
βββ config/ # site.ts, navigation, projects, experiences, popular tags
βββ lib/ # pure, tested logic + services (db, stats, spotify, github, token burn, og, seo)
βββ plugins/ # remark/rehype and Expressive Code plugins
βββ styles/ # theme.css (the only colour tokens), global, prose, animations, callouts
βββ integrations/ # dev-pages.ts: injects /dev/* under `astro dev` only
βββ dev-pages/ # token, component, content and MDX fixture pages (never built)
All variables are declared in the astro:env schema (astro.config.mjs) and are optional. The names are unchanged from v1, so the Vercel project needs no renames. See .env.example.
| Variable | Access | Vercel environments | Used for |
|---|---|---|---|
POSTGRES_URL |
server secret | Production, Preview | Views, reactions, blog stats (Neon -pooler host) |
GITHUB_API_TOKEN |
server secret | Production, Preview | /projects, /api/github, activity card, footer stars at build |
SPOTIFY_CLIENT_ID |
server secret | Production, Preview | Now playing |
SPOTIFY_CLIENT_SECRET |
server secret | Production, Preview | Now playing |
SPOTIFY_REFRESH_TOKEN |
server secret | Production, Preview | Now playing (regenerate) |
BUTTONDOWN_API_KEY |
server secret | Production, Preview | Newsletter signup. Read at build too: adding it later needs a redeploy |
TOKEN_BURN_INGEST_KEY |
server secret | Production only | Token burn ingest key (setup) |
NEXT_PUBLIC_GISCUS_REPO |
server public | Production, Preview | Giscus comments |
NEXT_PUBLIC_GISCUS_REPOSITORY_ID |
server public | Production, Preview | Giscus comments |
NEXT_PUBLIC_GISCUS_CATEGORY |
server public | Production, Preview | Giscus comments |
NEXT_PUBLIC_GISCUS_CATEGORY_ID |
server public | Production, Preview | Giscus comments |
UMAMI_WEBSITE_ID |
server public | Production, Preview | Umami analytics (script proxied through /stats/*) |
The NEXT_PUBLIC_ prefix is kept only to avoid renaming Vercel variables. These values are read on the server and rendered into pages; no variable uses context: 'client'. POSTGRES_URL_DIRECT (the Neon direct host) is a local-only tooling variable for bun run db:pull and manual migrations; the app never reads it. Vercel's system variables (VERCEL_GIT_COMMIT_SHA, VERCEL_GIT_COMMIT_REF, VERCEL_URL, VERCEL_BRANCH_URL) must stay exposed.
The card shows personal Claude Code usage. Claude Code's built-in OpenTelemetry export sends token and cost metrics every 60 s to POST /api/otel/v1/metrics, which adds them per day (Asia/Ho_Chi_Minh) and model into the token_burn_daily table. No background job or commits.
On each machine, run the setup script (it asks for TOKEN_BURN_INGEST_KEY, backs up and merges ~/.claude/settings.json, then checks the key):
curl -fsSL https://raw.githubusercontent.com/Karhdo/karhdo.dev/main/scripts/setup-token-burn-telemetry.sh | bashIt adds this to ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_ENABLE_TELEMETRY": "1",
"OTEL_METRICS_EXPORTER": "otlp",
"OTEL_EXPORTER_OTLP_PROTOCOL": "http/json",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT": "https://karhdo.dev/api/otel/v1/metrics",
"OTEL_EXPORTER_OTLP_METRICS_HEADERS": "Authorization=Bearer <TOKEN_BURN_INGEST_KEY>",
"OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE": "delta"
}
}- Only
claude_code.token.usageandclaude_code.cost.usageare stored, as daily totals; sessions, users and other attributes are dropped. Cost is Claude Code's estimate at API prices. - History before 2026-09-29 was imported once from ccusage (
scripts/import-token-burn-history.ts). - The ingest is protected by its key; it has no firewall rule (the Hobby plan allows only one rate-limit rule, used for
POST /api/stats). - Verify:
curl -s https://karhdo.dev/api/token-burnreturns"available": true.
If the Now playing card stays empty, check the token endpoint's error: invalid_client means the client id/secret pair is wrong (secret rotated or app deleted: copy the current secret from the dashboard), invalid_grant means the refresh token is revoked or expired: regenerate it as below. With the app's client id and secret from the Spotify developer dashboard:
-
In the app settings, add the redirect URI
http://127.0.0.1:8888/callback(Spotify only accepts loopback IPs, notlocalhost). -
Open this URL in a browser while signed in to the Spotify account to show, and approve:
https://accounts.spotify.com/authorize?client_id=<CLIENT_ID>&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A8888%2Fcallback&scope=user-read-currently-playing%20user-read-playback-state -
The browser lands on
http://127.0.0.1:8888/callback?code=β¦(the page itself fails to load; that's fine). Copy thecodevalue; it expires after a few minutes. -
Exchange it for tokens:
curl -s https://accounts.spotify.com/api/token \ -H "Authorization: Basic $(printf '%s:%s' "$SPOTIFY_CLIENT_ID" "$SPOTIFY_CLIENT_SECRET" | base64)" \ -d grant_type=authorization_code -d code=<CODE> \ -d redirect_uri=http://127.0.0.1:8888/callback
-
Put the returned
refresh_tokeninSPOTIFY_REFRESH_TOKEN(Vercel Production and Preview, and.envlocally), then redeploy. -
Verify: play something and
curl -s https://karhdo.dev/api/spotifyshows"isPlaying": true.
Postgres on Neon. The app connects through the -pooler host (PgBouncer, transaction mode, so prepare: false). Drizzle maps the existing v1 stats table and StatsType enum in src/lib/db/schema.ts; it never creates or changes them.
- Never run
drizzle-kit push,migrateorgenerate. There are no such scripts.bun run db:pullintrospects into a git-ignored folder, to diff against the hand-written schema. SetPOSTGRES_URL_DIRECT(the direct, non-pooler host) explicitly first. - The one schema addition,
stats_daily(daily views for the 30-day chart), is a reviewed, idempotent, additive-only SQL file run by hand withpsql: first on a Neon branch, then on production. Applied to production on 2026-09-27 (seedb/manual-migrations/README.md). Deploy order doesn't matter: the daily write is best-effort and tolerates a missing table.
Two Vercel projects:
| Site | Project | Source | Deploys |
|---|---|---|---|
| karhdo.dev (v2) | karhdo-blog |
main (production), other branches (previews) |
Git pushes |
| v1.karhdo.dev (v1) | karhdo-blog-v1 |
the v1 branch, not Git-linked |
by hand: from a v1 checkout, vercel link --project karhdo-blog-v1, then vercel deploy --prod |
v1 is a frozen archive with its own env (Next.js/pnpm, ENABLE_EXPERIMENTAL_COREPACK=1, which must never be set on karhdo-blog: Corepack rejects Bun). v1 is noindex and read-only: it shows stats from the shared stats table, but only v2 records views and reactions.
karhdo-blog settings:
- Framework preset Astro, install
bun install --frozen-lockfile, buildbun run build(also invercel.json). - Node.js 24.x for Functions. Don't set
bunVersioninvercel.json: that moves Functions onto the Bun runtime. - "Automatically expose System Environment Variables" on.
Rules:
- Never deploy with
astro build+vercel deploy --prebuiltwithoutvercel build.@astrojs/verceldoesn't mergevercel.jsoninto the build output; the platform does that invercel buildand Git deploys. Skipping it drops the security headers, the/stats/*Umami rewrite and the redirects. - Security headers (CSP, HSTS, β¦) live in
vercel.json, since static files never pass through a function. - Vercel Firewall rule "Rate limit stats writes" (live):
POST /api/statsat 30 requests / 60 s per IP, 429 above. The Origin check only stops browser CSRF; scripted clients can forgeOrigin. The Hobby plan allows only this one rate-limit rule, soPOST /api/newsletter(~5 / 60 s) needs a paid plan. - Buttondown uses double opt-in, since the form never sends
type: "regular". - Dependabot (
.github/dependabot.yml, Bun ecosystem) reads its config only from the default branch (main), so it takes effect once v2 is merged intomain.
- Tokyonight only. Every colour is a token from
src/styles/theme.css(Day in:root, Night in[data-theme="dark"]and theprefers-color-schemefallback), mirrored insrc/styles/palette.ts. No brand or off-palette hex values; brand logos are tinted with tokens.bun run lint:paletteenforces it in CI. - Contrast (Day theme).
--faintand--mutedfail WCAG AA for small text: use them for decorative marks and large text only, and--fg-soft/--fgfor meaningful small text. Never put--bgtext on--bluein Day; solid accent chips use a--heat-4background with--surface-solidtext. - Cache headers on on-demand routes. Never send
s-maxage/stale-while-revalidateinCache-Controlwithout a browsermax-age(browsers would serve stale data for the SWR window). UseCache-Control: public, max-age=0, must-revalidatefor browsers plusVercel-CDN-Cache-Control: max-age=N, stale-while-revalidate=Mfor the CDN. - Motion. Effects run only under
prefers-reduced-motion: no-preference, animate only transform/opacity/filter/clip-path, and content is complete at rest.
- Design and code patterns borrowed from Leo Huynh's leohuynh.dev (Astro rebuild), which v1 was also based on, along with Timothy Lin's Tailwind Next.js Starter Blog.
- Colours from Tokyonight by Folke Lemaitre (Apache-2.0). The light code theme is converted from its
extras/sublime/tokyonight_day.tmThemeat a pinned commit. - Emoji from Twemoji v17.0.3 by jdecked, graphics licensed under CC-BY 4.0.
- Brand logos from simple-icons (CC0), icons from Lucide (ISC), fonts Outfit and JetBrains Mono (SIL OFL 1.1).
- Blog images from Unsplash, GIFs from GIPHY, illustrations from Storyset.
Full licence notices are in THIRD_PARTY_NOTICES.md.
MIT Β© Do Trong Khanh (Karhdo). If this project helped you, a β is appreciated.
