Personal portfolio, telemetry dashboard, and API hub for Mike Odnis (WomB0ComB0), live at mikeodnis.dev.
- Architecture
- Tech Stack
- Routes & Pages
- API Reference
- Quick Start
- Environment Variables
- Scripts & Tooling
- Scope & Architecture Decisions
- License
The application runs as a single Next.js 16 deployment on Vercel, splitting UI rendering from external API orchestration:
- Presentation Layer (
src/app/(routes)): React 19 Server and Client Components styled with Tailwind CSS v4 and Radix UI primitives. Client data fetching uses a customDataLoaderbuilt on@effect/platform,effect/Schema, and@tanstack/react-query(useSuspenseQuery). - API Layer (
src/app/api): Two Elysia.js routers mounted inside Next.js App Router catch-all handlers (/api/[[...route]]and/api/v1/[[...route]]). Before routing, each request passes a guard that checks the client IP against the IP/CIDR ban list and per-route rate limits kept in Upstash Redis (see Rate Limits & Bans); routes also set security headers and validate input at runtime. - Content & Persistence:
- Sanity CMS v6: Structured content for projects, work experience, certifications, places, talks, presentations, articles, and resume metadata.
- Upstash Redis: API rate-limit counters and the IP/CIDR ban list, keyed by client IP address, for abuse prevention.
- Firebase v13: Authentication and real-time storage for the
/guestbookroute.
- Observability: OpenTelemetry instrumentation (
src/instrumentation.ts), Sentry error tracking, and Better Stack uptime monitoring and log ingestion.
graph TD
subgraph "Client & Edge"
Browser["Browser / PWA Service Worker"]
DataLoader["Effect DataLoader + TanStack Query"]
end
subgraph "Next.js 16 App Router"
RSC["Server Components & Route Groups"]
CoreAPI["Elysia Core (/api/*)"]
V1API["Elysia v1 (/api/v1/*)"]
end
subgraph "State & Storage"
Upstash[("Upstash Redis")]
Sanity[("Sanity CMS v6")]
Firebase[("Firebase Auth & DB")]
end
subgraph "Upstream Providers"
Spotify{{"Spotify Web API"}}
GitHub{{"GitHub GraphQL API"}}
WakaTime{{"WakaTime & LeetCode"}}
Lanyard{{"Discord / Lanyard"}}
Writing{{"Hashnode & Dev.to"}}
Google{{"GA4 & YouTube Data API"}}
end
Browser --> RSC
Browser --> DataLoader
DataLoader --> CoreAPI
DataLoader --> V1API
CoreAPI --> Upstash
V1API --> Upstash
V1API --> Sanity
V1API --> Firebase
V1API --> Spotify
V1API --> GitHub
V1API --> WakaTime
V1API --> Lanyard
V1API --> Writing
V1API --> Google
| Category | Technology | Usage |
|---|---|---|
| Runtime & Package Manager | Bun (>=1.0.0), Node.js (>=24.15.0) |
Dependency management, script execution, and local dev runtime |
| Framework | Next.js 16, React 19 | App Router, Server Components, dynamic code splitting, and PWA support |
| API Engine | Elysia.js 1.4, @elysiajs/eden |
Type-safe REST handlers, OpenTelemetry spans, Server-Timing headers, and CORS |
| Data Pipeline & Validation | Effect (@effect/platform, effect/Schema), Zod 4, ArkType |
Typed HTTP client pipelines, retry policies, and strict schema decoding |
| State & Caching | TanStack React Query v5, Jotai 3, Upstash Redis | Suspense query caching, atomic client state, and server-side API rate limits and ban list |
| Styling & Motion | Tailwind CSS v4, Radix UI, Motion 14, Paper Shaders | Component primitives, animations, and WebGL background shaders |
| CMS & Database | Sanity v6 (next-sanity), Firebase v13 (firebase, firebase-admin 14) |
Portfolio content management and authenticated guestbook entries |
| Observability | Sentry 11, OpenTelemetry, Better Stack (@logtail/node) |
Distributed tracing, error reporting, and /api/status uptime aggregation |
| Code Quality & Testing | Biome 2, TypeScript 6, Vitest 5, Playwright | Formatting, linting, static type checking, unit tests, and Chromium E2E tests |
| Route | Source | Description |
|---|---|---|
/ |
src/app/(routes)/page.tsx |
Home view with interactive canvas, command palette (Cmd+K), and quick links |
/projects, /projects/[id] |
src/app/(routes)/(main)/(professioal)/projects |
Full project catalog and individual project case studies from Sanity |
/experience, /experience/[id] |
src/app/(routes)/(main)/(professioal)/experience |
Career timeline and role details |
/certifications |
src/app/(routes)/(main)/(professioal)/certifications |
Technical certifications and credentials |
/resume |
src/app/(routes)/(main)/(professioal)/resume |
Interactive PDF resume viewer with Sanity-backed metadata |
/media |
src/app/(routes)/(dev)/media |
Unified feed of Hashnode and Dev.to blog posts, talks, presentations, and YouTube videos |
/stats |
src/app/(routes)/(dev)/stats |
Live engineering telemetry: GitHub contributions, WakaTime hours, LeetCode stats, Discord status, and site analytics |
/spotify |
src/app/(routes)/(main)/(personal)/spotify |
Real-time Spotify playback, top tracks, and top artists |
/places, /places/[placeId] |
src/app/(routes)/(main)/(personal)/places |
Curated map and directory of favorite locations using @vis.gl/react-google-maps |
/guestbook |
src/app/(routes)/(main)/(personal)/guestbook |
Authenticated visitor messages backed by Firebase |
/sponsor |
src/app/(routes)/(dev)/sponsor |
GitHub Sponsors tiers, current backers, and contribution links |
/privacy, /cookies, /licenses |
src/app/(routes)/(legal) |
Legal and open-source license disclosures |
| Method | Path | Description |
|---|---|---|
GET |
/api/health |
Liveness check with uptime and timestamp |
GET |
/api/status |
Service health aggregated from Better Stack Uptime monitors or status pages |
POST |
/api/admin/ban |
Bearer-authenticated IP and CIDR ban management (ADMIN_API_TOKEN); the JSON body's action picks ban, unban, slow, list, etc., and an optional seconds makes a ban, slow-mode or CIDR entry temporary |
| Method | Path | Upstream Source | Description |
|---|---|---|---|
GET |
/api/v1/now-playing |
Spotify Web API | Current playback state, track metadata, and album art |
GET |
/api/v1/top-tracks |
Spotify Web API | Short-term top tracks with resilient pagination fallback |
GET |
/api/v1/top-artists |
Spotify Web API | Short-term top artists and genres |
GET |
/api/v1/github-stats |
GitHub GraphQL API | Repositories, stars, followers, and contribution calendar |
GET |
/api/v1/github-sponsors |
GitHub GraphQL API | Active and past GitHub Sponsors and tier metadata |
GET |
/api/v1/wakatime |
WakaTime API | Total coding time and language breakdown |
GET |
/api/v1/leetcode |
LeetCode GraphQL | Solved problem counts by difficulty |
GET |
/api/v1/lanyard |
Lanyard API | Live Discord presence and custom activities |
GET |
/api/v1/blog |
Hashnode & Dev.to | Merged and chronologically sorted blog articles |
GET |
/api/v1/google |
Google Analytics / YouTube | Site pageviews and YouTube channel videos |
POST |
/api/v1/messages |
Firebase Admin | Sign the guestbook; needs a Firebase ID token (see Guestbook posts) |
GET |
/api/v1/sanity/* |
Sanity CMS | Sub-routes: /resume, /experiences, /projects, /projects/featured, /certifications, /places, /presentations, /talks, /articles, /videos |
Requests to /api/* and /api/v1/* pass an Elysia onRequest guard (src/lib/security/api-guard.ts) before routing. /api/health, CORS preflights and the separate /api/og image route are not guarded. Limits are sliding windows per client IP (IPv6 clients per /64 network):
| Route class | Limit | Ban list applies |
|---|---|---|
POST /api/v1/messages (signing the guestbook) |
5 posts per 10 min | Yes |
Other /api/v1/* |
60 requests per 60 s | Yes |
/api/admin/* |
5 requests per 10 s | No |
Other /api/* |
30 requests per 60 s | Yes |
| IPs in slow mode | 1 request per 30 s | Yes |
- Banned IPs and IPs inside a banned CIDR range receive
403; clients over a limit receive429withRetry-AfterandRateLimit-*headers. Both responses are sent withCache-Control: no-store. - Ban-list entries are sorted sets in Upstash Redis scored by their expiry time; permanent entries use a far-future score. Expired entries are ignored by the guard and deleted from every list whenever the ban API adds, removes or lists entries.
- The ban-list read and the rate-limit counter run as one parallel Upstash round trip with a 300 ms budget. An in-process limit of 100 requests per minute per instance also applies.
POST /api/v1/messages signs the guestbook. The /guestbook page lists the messages on the server, so there is no GET.
- Request:
Authorization: Bearer <Firebase ID token>(from the signed-in visitor, a guest included) and a JSON body{ "message": "..." }of 1 to 280 characters. - Checks, in order: the guard above (bans, then the 5-per-10-minute
guestbooklimit), the body schema (400), the token (401), then the text itself (400when nothing is left once markup is removed). Nothing is written until every check passes. - Token: verified on the server with
jose, by Firebase's rules for third-party JWT libraries: anRS256signature by one of Google's securetoken keys (named by the token'skid, fetched from Google's public JSON Web Key Set and cached),audthe project id,isshttps://securetoken.google.com/<project id>,expin the future,iatandauth_timein the past (10 s of clock skew allowed), and a non-emptysub(the uid). There is no revocation lookup. A missing, malformed, invalid or expired token gets401with{ "error": "..." }and aWWW-Authenticate: Bearer realm="guestbook"header; the body is the same in every case. A refused token's error code and the claim at fault (nothing else) are logged as an error. WhenGCP_PROJECT_IDandNEXT_PUBLIC_FIREBASE_PROJECT_IDdiffer, every post gets a reported500instead of a silent401. So does a failure to fetch Google's keys (unreachable, a timeout, or an answer that is not a key set), so the browser does not sign a valid visitor out. - Why not
firebase-admin/auth: Next loadsfirebase-adminat runtime, outside the bundle, and its auth entryrequire()sjose(an ES module) throughjwks-rsa. A Node runtime withoutrequire(esm)cannot load that, and because the whole/api/v1app shares one module withfirebase-admin, every v1 route (and/stats) then answers500.joseimported directly is bundled into the route.firebase-adminis still used for the Firestore writes. A unit test fails if any source file namesfirebase-admin/auth. - Stored: the checked text as plain text, and the name their Google or GitHub sign-in carries. A guest, or a sign-in method the site does not offer, is stored as
Anonymous. A name sent in the body is ignored. The name is the visitor's own Firebase profile name, which they can change, so theuidis what identifies a poster. The message collection is publicly readable, so theuidis not on the message: it is kept in a server-only document with the same id underartifacts/{appId}/private/data/messagePoster, written in the same batch, and never returned. - In the browser: the card sends the visitor's ID token. After a
401it sends the post once more with a token Firebase refreshes on purpose (a401stores nothing); refused again, it signs the visitor out and asks them to sign in again, keeping their text. - Limits of the check: guest sign-in uses the public web config, so anyone can get a valid token; the sign-in check gives each post a
uid, not spam resistance. The per-IPguestbooklimit is the control against floods (while Redis is failing, the guard lets requests through and only the per-instance in-process limit applies). - Firestore rules (deployed in the Firebase console; not in this repo): the messages collection
artifacts/{appId}/public/data/messagemust allow publicread(the page loader reads it with the client SDK) and deny every clientcreate,updateanddelete. Every other path,messagePosterincluded, must deny clients both reads and writes. The Admin SDK ignores rules, so the API still writes. If clients may write there, anyone can skip this API and its checks.
- Bun
>= 1.0.0 - Node.js
>= 24.15.0
git clone https://github.com/WomB0ComB0/portfolio.git
cd portfolio
bun install
cp .env.example .env.localPopulate the required keys in .env.local (see Environment Variables), then start the development server:
bun dev- App:
http://localhost:3000 - API v1:
http://localhost:3000/api/v1 - Health:
http://localhost:3000/api/health
Validation is enforced at build and startup via src/env.ts (@t3-oss/env-nextjs). Copy .env.example to .env.local.
| Variable | Scope | Description |
|---|---|---|
CSRF_SECRET |
Server | Secret key for CSRF token signing |
ADMIN_API_TOKEN |
Server | Bearer token for /api/admin/ban endpoints |
BETTERSTACK_API_KEY |
Server | Better Stack Uptime API key for /api/status |
NEXT_PUBLIC_SANITY_PROJECT_ID |
Client | Sanity CMS project ID (NEXT_PUBLIC_SANITY_DATASET defaults to production) |
NEXT_PUBLIC_FIREBASE_* |
Client | Firebase client config (API_KEY, AUTH_DOMAIN, PROJECT_ID, STORAGE_BUCKET, MESSAGING_SENDER_ID, APP_ID, MEASUREMENT_ID) |
GCP_* |
Server | GCP service account credentials (GCP_PROJECT_ID, GCP_PRIVATE_KEY_ID, GCP_PRIVATE_KEY, GCP_CLIENT_EMAIL, GCP_CLIENT_ID, GCP_CLIENT_X509_CERT_URL) |
UPSTASH_REDIS_REST_URL |
Server | Upstash Redis REST endpoint |
UPSTASH_REDIS_REST_TOKEN |
Server | Upstash Redis REST token (read/write; never expose it as NEXT_PUBLIC_) |
NEXT_PUBLIC_DISCORD_ID |
Client | Discord user ID for Lanyard presence |
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY |
Client | Liveblocks public key |
NEXT_PUBLIC_SENTRY_DSN |
Client | Sentry DSN for client/server error reporting |
GCP_PROJECT_ID is also the project whose Firebase ID tokens POST /api/v1/messages accepts, so it must name the same Firebase project as NEXT_PUBLIC_FIREBASE_PROJECT_ID. Verifying tokens needs only that project id; the other GCP_* values are the service account the guestbook writes with.
| Variable | Scope | Description |
|---|---|---|
SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, SPOTIFY_REFRESH_TOKEN |
Server | OAuth credentials for /api/v1/now-playing, /top-tracks, and /top-artists |
GITHUB_TOKEN |
Server | GitHub PAT (read:user, read:org) for /api/v1/github-stats and /github-sponsors |
WAKA_TIME_API_KEY |
Server | WakaTime API key for coding telemetry |
SANITY_API_TOKEN |
Server | Sanity read token for private datasets or draft previews |
YOUTUBE_SERVER_API_KEY |
Server | YouTube Data API v3 key |
BETTERSTACK_STATUS_PAGE_ID |
Server | Optional specific Better Stack status page ID |
BETTERSTACK_SOURCE_TOKEN, BETTERSTACK_INGESTING_HOST |
Server | Optional Better Stack Telemetry (Logtail) source token and host for log shipping |
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY, NEXT_PUBLIC_GOOGLE_MAPS_MAP_ID |
Client | Google Maps credentials for /places |
# Development & Production
bun dev # Start Next.js development server
bun run build # Run security audit, bundle cost analysis, and production build
bun start # Start production server
# Code Quality & Verification
bun run lint # Check with Biome (biome ci) and Stylelint; fails on errors, writes nothing
bun run lint:fix # Apply Biome and Stylelint fixes
bun run format # Format files with Prettier
bun run type # Run TypeScript compiler check (tsc --noEmit)
bun run test # Run unit tests via Vitest (vitest run)
bun run e2e:ci # Run Playwright E2E test suite on Chromium
# Bundle & Repository Maintenance
bun run analyze # Build with @next/bundle-analyzer and report bundle sizes
bun run scripts:package-cost # Measure dependency size impact
bun run scripts:check-unused # Detect unused files and exports
bun run scripts:copyright # Apply or verify Apache 2.0 license headersTo keep initial page loads fast and avoid client bundle bloat, several previously listed roadmap items have been completed or intentionally excluded:
- [x] Blog & Media Engine (Completed): Live at
/media,/api/v1/blog, and/api/v1/sanity/*. Posts are fetched concurrently from Hashnode (blog.mikeodnis.dev) and Dev.to (mikeodnis), alongside talks, presentations, and videos from Sanity and YouTube. An in-repo MDX compiler (@next/mdx,shiki) was skipped to keep server bundles small. [x] Sponsorship Page (Completed): Live at /sponsorwith GitHub GraphQL integration at/api/v1/github-sponsors.- [ ] Interactive 3D Stats via Three.js / React Three Fiber (Excluded for Performance):
/statsrenders telemetry with 2D SVG and Recharts components. Pulling inthreeand@react-three/fiberadds ~150–250 KB of gzipped client JavaScript plus WebGL context overhead, degrading Largest Contentful Paint (LCP) and Total Blocking Time (TBT) on mobile devices. - [ ] Internationalization / i18n (Excluded for Performance): All portfolio content and technical articles are authored in English. Adding i18n routing middleware introduces per-request edge latency, duplicates route trees (
/[locale]/...), increases client dictionary payloads, and fragments Redis/CDN cache keys.
Licensed under the Apache License, Version 2.0. See the license headers in source files or http://www.apache.org/licenses/LICENSE-2.0 for full terms.