Skip to content

Repository files navigation

tabsircg

Source for tabsircg.com: the public portfolio + blog (apps/portfolio) and the CMS that feeds it (apps/admin). pnpm workspace, Next.js 16 on both sides, shared Zod schemas in packages/schemas.

apps/portfolio  ──REST──►  apps/admin  ──►  Firestore + Cloudflare R2
   (public)                  (private CMS)        │
        └──── events ──►  analytics-worker  ──►  Tinybird

ARCHITECTURE.md has the data flow, rendering matrix and per-app layout.


The apps

apps/admin — Next.js 16, port 5000

Private CMS. Notion-style block editor via @open-notion/editor, drafts → publish flow, featured-post management, blog taxonomy, plus an analytics dashboard reading Tinybird.

Image uploads go to Cloudflare R2 via presigned URLs. Auth is one jose JWT cookie gated by ADMIN_USERNAME / ADMIN_PASSWORD — no user table, single tenant. LinkedIn OAuth for cross-posting. Optional Claude Agent SDK for AI-assisted authoring.

apps/portfolio — Next.js 16, port 3001

Public site. Animated hero, services, work showcase, testimonials, contact. Blog index with a featured slot plus a cursor-paginated list. Post pages have TOC, share buttons and the felt meter. Legal pages, dynamic sitemap, robots.txt, OG metadata.

/api/revalidate is how admin pushes fresh content without a redeploy. /api/score proxies reactions to admin so the felt-id cookie stays same-origin.

apps/analytics-worker

Cloudflare Worker at analytics.tabsircg.com. Validates events against a KV origin allowlist and writes rows to Tinybird. Holds the .datasource DDL.

packages/

Package Role
@tabsircg/schemas Shared Zod schemas + types. Exports .ts source directly — no build, no dist. Modules: blog, analytics, api, ai, user, dashboard
@tabsircg/analytics Browser tracker SDK + server-side crawler middleware. Published to npm
@tabsircg/analytics-mcp MCP server exposing the analytics data

Releasing @tabsircg/analytics

The portfolio loads the tracker from unpkg, so the npm release — not this checkout — is what the live site runs. Releasing happens locally, before the push:

# bump "version" in packages/analytics/package.json, then
pnpm release:analytics

That typechecks, tests, builds, packs with pnpm (which rewrites workspace:*), publishes, and waits for unpkg to serve the new version. It no-ops if the version is already on npm, so it is safe to re-run.

A pre-push hook (.githooks/pre-push) refuses a push to main when the workspace has run ahead of the registry — either the package changed without a version bump (*.md and *.test.ts don't count), or the version in the manifest isn't published yet. It fails open if npm is unreachable, and --no-verify bypasses it. The hook arms itself through core.hooksPath, set by the root prepare script on install.

Publishing uses your own npm login, so npm login once and expect a 2FA prompt.


Stack

Layer Choice
Framework Next.js 16 (App Router, Turbopack), React 19
Language TypeScript 5.9
Styling Tailwind v4 (CSS-first @theme), tw-animate-css
UI premium-ds, @base-ui/react, lucide-react
Validation Zod 4
Data Firestore via firebase-admin; Tinybird for analytics
Storage Cloudflare R2 (@aws-sdk/client-s3)
Editor @open-notion/editor
Auth jose JWTs in HTTP-only cookies
State swr, zustand, server actions
Charts recharts
Tests vitest
Runtime Node 24.13 (.nvmrc), pnpm workspaces
AI @anthropic-ai/claude-agent-sdk

Run it

pnpm install
pnpm dev               # both apps: admin :5000, portfolio :3001
pnpm dev:admin         # just admin
pnpm dev:portfolio     # just portfolio

Each app loads its own .env — Next doesn't read env files from the workspace root, so shared values are duplicated.

apps/admin/.env

RUNTIME=development
ADMIN_USERNAME=...
ADMIN_PASSWORD=...
JWT_SECRET=...
FIREBASE_PRIVATE_KEY=...
FIREBASE_CLIENT_EMAIL=...
CLOUDFLARE_R2_AK_ID=...
CLOUDFLARE_R2_AK=...
CLOUDFLARE_R2_ENDPOINT=...
LINKEDIN_CLINET_ID=...
LINKEDIN_CLINET_SECRET=...
SERVER_TOKEN=...
ANTHROPIC_AUTH_TOKEN=...

apps/portfolio/.env

ADMIN_ORIGIN=http://localhost:5000
SERVER_TOKEN=...   # must match admin's
cd apps/admin && pnpm emulators   # Firebase emulators
pnpm mirror:firestore             # copy production Firestore into the emulator
pnpm seed:analytics               # seed Tinybird

Commands

Command What it does
pnpm dev Both apps in parallel
pnpm dev:admin / pnpm dev:portfolio One app
pnpm dev:clean Wipe .next/, then dev
pnpm build Build both
pnpm tc Typecheck both
pnpm test vitest in every workspace
pnpm mirror:firestore Copy production Firestore into the local emulator
pnpm seed:analytics Seed Tinybird
pnpm clean:pnpm Nuke node_modules + lockfile, reinstall

Wire contracts

Every admin REST response goes through wrapRoute (appUtils.ts):

type ApiResponse<T> =
  | { status: "success"; data: T }
  | { status: "error"; message: string };

Portfolio's fetchJson unwraps it. List endpoints (just /api/blogs) wrap data once more:

interface CursorPage<T> {
  items: T[];
  nextCursor: string | null;  // `${orderByValue}__${blogId}`
}

The serverToken header is portfolio's auth into admin. It's enforced at the proxy (proxy.ts), scoped to public read endpoints only — never the analytics dashboard, content writes, or the upload presigner, which need the admin JWT.

Public API (admin)

GET    /api/blogs                  paginated list (status, kind, tag, cursor, limit, orderBy)
GET    /api/blogs/featured         current featured post
GET    /api/blogs/nav              slug/title/date index for prev/next
GET    /api/blogs/[slug]           single published post, with prev/next
POST   /api/blogs/[slug]/score     react to a post
GET    /api/site-config            global site config
GET    /api/config/portfolio       portfolio content
GET    /api/analytics/*            dashboard aggregates (admin JWT only)

Things that bite

Featured is a timestamp, not a boolean. featuredAt: number | null; the published blog with the highest non-null value wins. Featuring is an explicit action (featureBlog(blogId)). There's no unfeatureBlog, so once anything has been featured something always is — that's the design. Needs the (status, featuredAt desc) composite index in firestore.indexes.json.

Blog content is a JSON string, not an object. PublishedBlogDB.content and BlogDraftDB.content hold JSON.stringify(DocContent). Parse/stringify happens at the boundary — blogUtils.ts on the admin side, posts.ts on portfolio's. Treat it as DocContent everywhere else.

Wire types vs view types. Wire shapes live in @tabsircg/schemas. View shapes (Post, PostMeta, Neighbour in posts.ts) are portfolio-only — ISO date strings, computed prev/next. Don't move view types into the shared package.

The no-build workspace. @tabsircg/schemas is internal-only and never published. Its exports map points at .ts source, both apps transpilePackages it, Turbopack reads source, HMR is instant. Don't add a build step — you'd need dual "development"/"production" exports conditions and you'd lose the live edit story.


Layout

personal/
├── apps/
│   ├── admin/                  Next.js 16 CMS (port 5000)
│   │   ├── src/app/api/        REST endpoints
│   │   ├── src/app/dashboard/  Authoring + analytics UI
│   │   ├── src/scripts/        Seeders and migrations
│   │   └── firestore.{rules,indexes.json}
│   ├── portfolio/              Next.js 16 public site (port 3001)
│   └── analytics-worker/       Cloudflare Worker + Tinybird DDL
└── packages/
    ├── schemas/                @tabsircg/schemas — Zod sources, no build
    ├── analytics/             @tabsircg/analytics — tracker SDK + crawler middleware
    └── analytics-mcp/          MCP server over the analytics data

CLAUDE.md has the deeper notes — gotchas, schema migration policy, open work. Read it before changing the wire format or the dev story.


Smaller stuff

  • Admin's tsconfig.json has exactOptionalPropertyTypes: true. Don't pass undefined for optional props — use {...(x ? { prop: ... } : {})}. Portfolio doesn't have that flag.
  • pnpm-lock.yaml lives at the workspace root only.
  • @open-notion/editor is a peer dep of @tabsircg/schemas (only DocContent is referenced). Both apps install it directly.

Deployment

Both apps are Next.js 16 standalone builds on Vercel. Firestore is production Firebase. The analytics Worker deploys to Cloudflare. Admin's push to portfolio's /api/revalidate is how content updates ship without a redeploy.

About

Source of tabsircg.com: Next.js 16 + React 19 on a custom CMS, tag-based ISR pushes new content live without a redeploy.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages