Personal website of Kai Chen — production: kaichen.dev.
This repository is a Next.js 16 application using the App Router, React 19, TypeScript, and Tailwind CSS 4. It is deployed on Vercel.
| Resource | URL |
|---|---|
| Production site | https://kaichen.dev |
| Source | https://github.com/kaiiiichen/kaichen.dev |
- Overview
- Requirements
- Quick start
- npm scripts
- Repository layout
- Technology stack
- Routes and features
- API routes
- Environment variables
- External integrations
- Local development
- Testing
- CI, Dependabot, and auto-merge
- Git hooks
- Deployment
- Documentation map
- Forking this project
- License
The site combines:
- A marketing-style home page (identity, social icons with brand-colored hovers, Spotify listening card, Berkeley weather, GitHub pinned repositories via GraphQL with a static fallback, and Substack headlines).
- Dynamic data from Spotify, GitHub, Open-Meteo, and optional Supabase-backed listening history.
- Notes are hosted externally on Notion (via the top navigation link).
- Optional observability via Sentry (client, server, edge) and Vercel Analytics / Speed Insights.
UI / typography: The root body uses Geist Sans (font-sans). Nunito (via @fontsource/nunito) is used for the top nav, magazine-style cards (.mag-card / .mag-label), and most English copy inside those surfaces so the chrome stays consistent. Theme defaults to light when unset; .dark on <html> comes from the theme script + provider.
Now playing: The client hook app/hooks/use-now-playing.ts polls GET /api/spotify/now-playing about every 10s (cache: "no-store").
There is no middleware.ts (or proxy.ts) in this repo — every route is publicly accessible and rendered by the App Router directly.
| Tool | Version / notes |
|---|---|
| Node.js | 20.x (matches CI and @types/node) |
| npm | 9+; lockfile is package-lock.json — use npm ci for reproducible installs |
git clone https://github.com/kaiiiichen/kaichen.dev.git
cd kaichen.dev
npm install
cp .env.example .env.localEdit .env.local following Environment variables. You do not need every key to run the app locally; missing keys typically degrade or hide features rather than crash the build (exceptions: pages that import Supabase at module scope use placeholder values in CI — see below).
Start the dev server:
npm run devOpen http://localhost:3000.
Important: dev and build both pass --webpack to Next.js. Keep local and production behavior aligned.
| Script | Command | Purpose |
|---|---|---|
dev |
next dev --webpack |
Local development with Webpack. |
build |
next build --webpack |
Production bundle (also runs type checking as part of Next). |
start |
next start |
Serve the last build output (run build first). |
lint |
eslint |
ESLint across the repo (eslint.config.mjs). |
typecheck |
tsc --noEmit |
TypeScript without emitting JS. |
test |
vitest run |
Unit tests once (CI uses this). |
test:watch |
vitest |
Vitest in watch mode. |
postinstall |
git config core.hooksPath .githooks … |
Points Git at .githooks/ so the prepare-commit-msg hook runs after npm install (see Git hooks). |
Before opening a PR, run the same sequence as CI:
npm run lint && npm run typecheck && npm run test && npm run buildHigh-level map (not every file):
kaichen.dev/
├── app/ # App Router
│ ├── layout.tsx # Root layout: fonts, theme script, Nav, Providers, Analytics
│ ├── page.tsx # Home
│ ├── globals.css
│ ├── global-error.tsx # Root error boundary + Sentry
│ ├── opengraph-image.tsx # OG image for /
│ ├── about/ # Bio / CV-style page + OG
│ ├── projects/ # Projects + GitHub heatmap + OG
│ ├── api/ # Route handlers (Spotify, GitHub, weather)
│ ├── components/ # UI: nav, mobile-nav, cards, theme, weather, listening, GitHub heatmap, …
│ ├── hooks/ # use-now-playing.ts (Spotify poll)
│ └── lib/ # og.tsx, substack RSS, GitHub pinned repos (GraphQL)
├── lib/ # Shared server-oriented helpers + Vitest tests
│ ├── now-playing.ts # Types for now-playing payload
│ ├── spotify-now-playing-helpers.ts
│ ├── spotify-access-token.ts
│ ├── listening-supabase.ts
│ ├── weather-open-meteo.ts
│ └── *.test.ts
├── mdx-components.tsx # MDX element mapping
├── next.config.ts # Webpack config + withSentryConfig
├── instrumentation.ts # Sentry Node/Edge registration
├── instrumentation-client.ts # Sentry browser + router transition hooks
├── sentry.server.config.ts
├── sentry.edge.config.ts
├── vitest.config.ts
├── eslint.config.mjs
├── .githooks/ # Git hooks (co-author trailer)
├── .github/
│ ├── workflows/ # ci.yml, auto-merge.yml
│ ├── dependabot.yml
│ ├── ISSUE_TEMPLATE/
│ └── pull_request_template.md
├── .env.example
├── AGENTS.md # AI agent / automation git rules
├── CLAUDE.md # Short context for Claude Code (points here + AGENTS)
├── CONTRIBUTING.md
├── SECURITY.md
├── CODE_OF_CONDUCT.md
└── LICENSE # GPL-3.0
| Layer | Choices |
|---|---|
| Framework | Next.js 16.2 (App Router), React 19, TypeScript 5 |
| Styling | Tailwind CSS 4 (@tailwindcss/postcss), shared UI tokens + magazine cards in app/globals.css (.mag-card, .mag-label) |
| Content | MDX / Markdown page extensions are enabled in next.config.ts via @mdx-js/loader (+ remark-gfm, remark-math, rehype-katex, rehype-highlight); mapping in root mdx-components.tsx. There are no app/**/*.mdx routes in-tree yet—add files when you want long-form pages. |
| Fonts | @fontsource/* (Nunito, JetBrains Mono), geist (Geist Sans / Mono as CSS variables on <html>, default font-sans on <body>) |
| Data | Supabase (@supabase/supabase-js) — optional listening history DB writes (service role) for /api/spotify/now-playing |
| Monitoring | @sentry/nextjs (optional DSN), Vercel Analytics + Speed Insights |
| Testing | Vitest 4 |
Pinned versions are in package.json.
| Route | What it does |
|---|---|
/ |
Identity block, social links (mailto + GitHub, LinkedIn, X, Spotify), Listening + Location cards (Spotify + Open‑Meteo: temp, condition, feels-like, humidity, local America/Los_Angeles clock via berkeley-time.tsx), pinned GitHub repos (GraphQL + fallback list), Substack RSS snippets |
/about |
Education, experience, courses, volunteering |
/projects |
Project cards + GitHub contribution calendar (client component, data from /api/github/contributions) |
External nav (no in-app route): the main nav includes Notes → Notion and Blog → Substack; there is no /notes or /blog route in this repo.
Open Graph: several routes ship opengraph-image route handlers for social previews. Set metadataBase in app/layout.tsx if you see build warnings about resolving OG image URLs.
All handlers live under app/api/.
| Method & path | Behavior | Caching / notes |
|---|---|---|
GET /api/spotify/now-playing |
Spotify me/player/currently-playing + recently-played; optional service-role merges legacy listening_stats rows into spotify:<trackId>; writes listening_* while is_playing |
Cache-Control: public, s-maxage=10, stale-while-revalidate=5; SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, SPOTIFY_REFRESH_TOKEN; in-memory lastKnownTrack fallback |
GET /api/github/contributions |
GraphQL contribution calendar + REST search for latest commit + REST repo metadata for star counts | dynamic = force-dynamic; Cache-Control: no-store; requires GITHUB_TOKEN |
GET /api/github/stars?repo=owner/name |
Returns stargazers_count and archived for a repo |
revalidate = 3600; optional GITHUB_TOKEN for rate limits |
GET /api/weather |
Open-Meteo current conditions for fixed Berkeley coordinates (temperature_2m, weathercode, apparent_temperature, relative_humidity_2m, hourly rain chance) |
fetch with next.revalidate = 600; parsed in lib/weather-open-meteo.ts |
Copy .env.example to .env.local. Never commit real secrets.
| Variable | Role |
|---|---|
NEXT_PUBLIC_SUPABASE_URL |
Supabase project URL. Paired with SUPABASE_SERVICE_ROLE_KEY inside /api/spotify/now-playing for the optional listening history. |
SUPABASE_SERVICE_ROLE_KEY |
Server-only. Used by /api/spotify/now-playing for DB reads/writes against listening_history / listening_stats — keep off the client bundle. |
SPOTIFY_CLIENT_ID / SPOTIFY_CLIENT_SECRET / SPOTIFY_REFRESH_TOKEN |
Spotify app + user refresh token (scopes: user-read-currently-playing, user-read-recently-played). If unset, the route falls back to memory + DB for “last played.” |
GITHUB_TOKEN |
Fine-grained or classic PAT for GitHub API (contributions + stars + pinned repos on the home page). If missing, contribution/stars features may error or return empty data; pinned projects fall back to a static list in app/lib/github-pinned.ts. |
GITHUB_LOGIN |
Optional. GitHub username for pinned repositories and related API calls (defaults to kaiiiichen if unset). Set when forking so the home page shows your pins. |
| Variable | Role |
|---|---|
NEXT_PUBLIC_SENTRY_DSN / SENTRY_DSN |
Error reporting; see instrumentation.ts and Sentry configs. |
SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT |
Build-time source map upload for readable stack traces in Sentry (configure on Vercel, not in git). |
vercel env pull .env.vercel.checkThat path is gitignored — do not commit it.
CI does not need any real Supabase keys to build — every Supabase client in this repo is constructed lazily inside a function body, so next build succeeds without NEXT_PUBLIC_SUPABASE_* set. See .github/workflows/ci.yml.
| Service | Use in this repo |
|---|---|
| Spotify Web API | Current + recently played track |
| GitHub GraphQL | Contribution calendar |
| GitHub REST | Repo stars, commit search |
| Open-Meteo | Weather (no API key); Berkeley lat/long in app/api/weather/route.ts |
| Supabase | Optional listening_history / listening_stats writes (service role) for /api/spotify/now-playing |
| Substack RSS | Home page “latest posts” (app/lib/substack.ts) |
- Node 20, npm install then
npm run dev. - Supabase: only
/api/spotify/now-playinguses Supabase (service role) for the optional listening history. The site builds without Supabase env; when it is unset, the route skips DB reads/writes and still uses Spotify ifSPOTIFY_*is configured.
| Symptom | Things to check |
|---|---|
| GitHub widgets empty | GITHUB_TOKEN set and not expired; API rate limits. |
| "Recently played" never persists across deploys | NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY set; tables listening_history / listening_stats exist with the expected columns. |
| Sentry noisy locally | DSN unset disables reporting; or lower sample rate in instrumentation-client.ts. |
Unit tests use Vitest and live under lib/*.test.ts: lib/substack-rss.test.ts, lib/weather-open-meteo.test.ts, lib/spotify-now-playing-helpers.test.ts.
npm run test
npm run test:watchThere are currently no Playwright/E2E tests in this repo; manual browser checks matter for layout and visual polish.
Triggers on push and pull_request to main:
npm ci → lint → typecheck → test → build on ubuntu-latest, Node 20, with npm cache.
Dependabot (.github/dependabot.yml)
- npm and github-actions ecosystems, weekly (Monday 09:00 America/Los_Angeles).
- Grouped updates (fonts, Sentry, Supabase, MDX-related, Vercel, types, catch-all minor/patch).
- Ignored semver-major bumps for core tooling (
next,react,eslint,typescript,tailwindcss, …) so those upgrades stay manual.
Auto-merge (.github/workflows/auto-merge.yml)
Runs only when the PR author is dependabot[bot]:
- Reads semver classification via
dependabot/fetch-metadata. - For patch and minor updates: enables
gh pr merge --auto --squash(respects branch protection when checks pass). - On open / reopen, posts an idempotent PR comment that includes the official
@dependabot squash and mergeline (documentation + redundancy; primary merge path is still GitHub auto-merge).
pull_request types include synchronize so Dependabot force-pushes re-enable auto-merge. Concurrency is scoped per PR number to avoid overlapping runs.
After npm install, postinstall runs:
git config core.hooksPath .githooks.githooks/prepare-commit-msg appends:
Co-authored-by: Claude <noreply@anthropic.com>
to non-merge commits via git interpret-trailers (idempotent). Automation that cannot run hooks should add the same trailer manually — see AGENTS.md.
- Connect the GitHub repository to Vercel.
- Set environment variables in the Vercel project (production + preview as needed), especially
GITHUB_TOKEN, Spotify keys, and the Supabase variables if you want listening history persistence. - Pushes to
maintypically deploy production; preview deployments use PR branches.
Manual CLI (after vercel link):
vercel --prod| File | Audience | Contents |
|---|---|---|
| README.md (this file) | Everyone | Setup, architecture, APIs, env, CI |
CONTRIBUTING.md |
Human contributors | How to PR, conventions, CI parity |
AGENTS.md |
AI agents / automation | Branch + PR only, co-author trailer, secrets |
CLAUDE.md |
Claude Code | Short pointer + stack summary |
SECURITY.md |
Security researchers | How to report issues responsibly |
CODE_OF_CONDUCT.md |
Contributors | Contributor Covenant |
.env.example |
Developers | Variable names and brief comments |
.github/pull_request_template.md |
PR authors | Checklist |
Cursor-specific rules live under .cursor/rules/ (IDE-only, not required reading for all contributors).
Replace at minimum:
| Area | Where to look |
|---|---|
| Copy, links, projects list, social URLs | app/page.tsx, app/projects/page.tsx, app/about/page.tsx |
| Spotify OAuth app + refresh token | Spotify Developer Dashboard; env SPOTIFY_* consumed in lib/spotify-access-token.ts |
| GitHub login / repos / pins | app/api/github/contributions/route.ts, app/components/project-stars.tsx, app/lib/github-pinned.ts, env GITHUB_LOGIN |
| Supabase tables | lib/listening-supabase.ts, app/api/spotify/now-playing/route.ts, Supabase dashboard (listening_history, listening_stats) |
| Substack feeds | app/lib/substack.ts |
| Weather location | app/api/weather/route.ts, weather UI components |
| Theme / fonts | app/layout.tsx, app/globals.css, app/components/theme-provider.tsx |
Keep LICENSE compliance if you redistribute (GPL-3.0).
This project is licensed under the GNU General Public License v3.0 — see LICENSE.
Please read SECURITY.md before reporting vulnerabilities.