JootaCee Platform Architecture — How This Site Is Built
A full technical breakdown of the JootaCee portfolio platform: static export, custom i18n, Git-First CMS, and every architectural decision that shaped the…
This is the canonical reference for how jootacee.com is built. It covers the full stack from Next.js 16.2.6 App Router with static export, through a custom i18n system that replaced next-intl, to the ADR-008 Git-First CMS decision that deprecated Supabase. Every tradeoff is documented.
Contents
Why Document Your Own Site?
Most portfolio sites are throwaway projects. This one is not. JootaCee is a production system with a real CMS, a CI/CD pipeline enforcing zero-error builds, 400+ tests, Lighthouse gates in CI, and an evolving architecture documented via Architecture Decision Records. If it's worth building correctly, it's worth documenting precisely.
This article is the canonical reference. What follows is not aspirational — it describes exactly what is running in production.
Core Stack
The runtime choices:
- Next.js 16.2.6 with the App Router, configured with
output: 'export'for fully static generation - React 19 — concurrent features, improved Suspense, and better hydration
- TypeScript strict mode throughout, zero
anywithout a justification comment - TailwindCSS v4 with CSS cascade layers and design tokens centralized in
src/styles/ui.ts - Framer Motion for UI animations, loaded via
LazyMotionto avoid shipping the full bundle on initial load - GSAP for scroll-driven and complex timeline animations
- React Three Fiber + Drei for 3D scenes, always lazy-loaded
- Vitest + React Testing Library for 400+ unit and integration tests
- Zod at every system boundary that touches persisted or external data
The Static Export Constraint Is a Feature
output: 'export' in Next.js eliminates server-side rendering entirely. Every page is generated at build time and deployed as flat HTML files. This constraint is non-negotiable and it shapes every other decision.
What this means in practice:
- No
getServerSideProps, noheaders(), nocookies(), no server functions - No API routes —
/api/*does not exist in the static export - Every dynamic route needs
generateStaticParams()returning[{locale:'en'},{locale:'es'}] - No
fetch()to localhost at runtime — data is either baked into the build or managed client-side
The payoff is significant: the entire site deploys to Cloudflare Pages as static files with zero cold starts, zero server maintenance, and a global CDN edge network by default. Performance ceiling is much higher. Reliability is near-perfect.
The cost is architectural discipline. Every feature request must first pass the question: "can this work without a server?" For most features on a portfolio site, the answer is yes.
Why next-intl Was Replaced
The original plan used next-intl for internationalization. This worked during development but failed at build time. next-intl v4 depends on requestLocale() which internally calls headers() — a server-only runtime API. Static export has no concept of a request header; there is no server processing requests.
The replacement is a custom i18n system in src/lib/i18n/:
I18nProvider— React context that holds the messages for the current localeuseTranslations(namespace)— hook that resolves dot-notation keys, returnsanyto support strings, arrays, and nested objectsuseLocaleRouter— locale-aware navigation without server dependenciesLocaleLink— anchor component that prepends the current locale to hrefs
The locale layout in src/app/[locale]/layout.tsx passes key={locale} to I18nProvider. Without this, Next.js client-side navigation does not remount the layout tree, and the stale English messages persist when switching to /es/. This is a non-obvious Next.js behavior that cost two hours to debug.
Messages are imported as static JSON at build time. No network request, no server call. Spanish translation parity is enforced — every key in messages/en.json must exist in messages/es.json before a commit can land.
ADR-008: Why Supabase Was Deprecated
The most significant architectural decision in the project's history is ADR-008: Git is the canonical content source. Supabase is frozen.
Supabase was originally used as a database for CMS content. The decision to deprecate it came from three compounding problems:
Problem 1: Mismatch with static export. A database-backed CMS requires API calls at runtime or at build time. Runtime calls don't work in a static export. Build-time calls add fragile network dependencies to the CI pipeline. Either path requires infrastructure that must stay running.
Problem 2: Content is not versionable in a database. Git gives you content history, branching, diff-reviewed changes, and rollbacks for free. Supabase gives you row-level audit logs if you build them. The asymmetry is enormous for a developer-owned site.
Problem 3: SEO and portability. Content in a Postgres table is not inspectable without a client. Content in src/content/*.mdx is readable by any tool, crawlable by static analyzers, and portable to any future system.
The migration path (Phase 2 in AGENTS.md) moved all content to src/content/ structured directories: articles/, projects/, resources/, labs/, systems/. Each content type has a JSON Schema definition in src/content/_schema/ and a Zod schema in src/lib/content/schema.ts for runtime validation.
The freeze rules are enforced at the code level: no new imports from @supabase/supabase-js or src/lib/supabase/ are permitted. Any attempt to add them surfaces in ESLint via a custom rule.
Admin Panel Architecture
The admin panel at /admin is a full CMS dashboard. It manages site configuration, content visibility, design tokens, and analytics. No server. No database. State lives in the browser.
The state model:
useReducerwith a typedAdminActiondiscriminated union — 70+ action types, no direct state mutation- React Context distributes state to all panel components without prop drilling
localStoragepersistence under the key'jootacee-command-v2'with 800ms debounce- IndexedDB as a parallel write for larger payloads
- Zod validation on every load —
AdminStateSchema.partial().safeParse()handles schema evolution gracefully
The reducer is split across 10 domain slice files in src/lib/admin/slices/. Each slice handles a bounded subdomain: site config, design tokens, content blocks, navbar, personality, results. The root reducer delegates to slice handlers via a SLICE_HANDLERS map. This made a 2000-line monolithic reducer manageable and testable.
Panel routing uses a simple pattern: AdminShell.tsx maintains navigation groups and active panel state; PanelRouter.tsx switches on the active panel ID and renders the correct component. Adding a new panel requires touching exactly 7 files — documented in the engineering constitution.
Testing Infrastructure
Tests run in Vitest with jsdom. The setup mirrors a real browser environment with mocks for next/navigation, next-themes, and IntersectionObserver. The IntersectionObserver mock requires a regular function (not an arrow function) to support construction via new — this is the kind of detail that wastes an afternoon if you don't know it.
Coverage targets:
- Every custom hook has at least one behavioral test
- Every utility function has edge-case coverage
- UI components are tested via user interaction, not DOM structure inspection
The pre-commit hook (Husky + lint-staged) runs ESLint, tsc --noEmit, and Vitest on staged files before any commit lands. CI runs the full suite across three jobs: quality (typecheck + lint + tests), build (static export), and lighthouse (accessibility ≥ 95, SEO = 100).
Performance Baseline and Constraints
Current Lighthouse scores: Performance 44, Accessibility 96, Best Practices 96, SEO 100. The performance gap is well-understood: Three.js/R3F adds significant parse time even when lazy-loaded. The JS bundle is ~2.1 MB gzipped across 106 chunks. TBT (Total Blocking Time) is the primary bottleneck.
Mitigations already in place:
- All landing sections are
React.lazy()+Suspense— the hero and navigation are the only synchronously loaded components LazyMotionfrom Framer Motion loads only the features actually used- Three.js scenes load only when their section is in the viewport
- Font optimization via
next/font/googlewithdisplay: 'swap'
The target for Phase 4 is Lighthouse Performance ≥ 55. Getting above 70 with R3F in the tree requires either removing 3D scenes or accepting the tradeoff. For a portfolio demonstrating 3D capability, the tradeoff is intentional.
What Worked, What Didn't
Worked well: The useReducer + Context pattern scales cleanly for complex admin state. Zod at the persistence boundary has caught schema mismatches in production three times. The custom i18n system is simpler and more debuggable than any library alternative. Static export forces architectural clarity — you cannot take shortcuts that depend on a server.
Didn't work: Starting with Supabase before validating the static export constraint was a mistake that required a full migration. The next-intl assumption cost time. Turbopack SST cache corruption is a real operational hazard — the npm run clean script exists because of this, not as a precaution.
The engineering constitution (CLAUDE.md) captures all of these decisions as enforceable laws. New contributors do not have to rediscover them. The architecture is documented, tested, and gated by CI. That is the goal.