Imported from nordeim/personalized-tutor-app (
skills/nextjs-postgresql-single-app/SKILL.md). Install upstream withnpx skills add nordeim/personalized-tutor-app --skill nextjs-postgresql-single-app. Copyright stays with the author.
home-financing (ModFii) — Production Web App Skill
Refer to the codebase in https://github.com/nordeim/home-financing.git as the foundation/example to scaffold other projects using similar tech stacks.
Reusable distillation for any Next.js 16 App Router + React 19 + TypeScript 5.9 strict + Tailwind CSS v4 CSS-first @theme + Drizzle ORM 0.45 + PostgreSQL 17 + pg Pool singleton + Vitest 4.1 + Playwright 1.63 project. Generalizes beyond mortgages to any content-driven marketing + transactional funnel app (editorial guides, calculators, lead capture, lender/product matching). What it teaches: 20-section production reference distilled via Six-Phase Archaeology — Project Identity & Design Philosophy (editorial + brutalist restraint, forest/cream/amber tokens), Tech Stack & Versions (pinned), Bootstrapping & Config (tsconfig strict, next.config redirects/images.unoptimized, eslint flat, drizzle.config dual, docker-compose PG17 + pgcrypto/pg_trgm), Design System (verbatim @theme + radii/shadow/ease + typography), 5-Layer Architecture (file corpus → catalog → ensureSeeded projection → Pool singleton → RSC/force-dynamic → redirects), Component Map (Server Components by default, 6 use-client islands), Hooks (inlined useId/adjust-during-render/passive: true/body-lock), Content Ingestion (file-backed JSON → ensureSeeded count>0 + onConflictDoNothing → PG, 152 sitemap locs), A11y (WCAG AAA contrast/focus/prefers-reduced-motion/axe critical: []), 14 Anti-Patterns + Debugging Guide (H4/OOM server kill, second Pool EMFILE, .env leak d572d73, missing hero images, setState-in-effect, tailwind.config drift), Pre-Ship Gate (db:setup → lint 0/0 → typecheck → test 41/41 → build 43/43 (35+8) → e2e 121/121), Lessons/Pitfalls/Best Practices, 6 Copy-Paste Patterns (canonical funnel rateLimit→json→validate→seed→match→persist, health probe, idempotent projection, PMI 0.0065 + 1.15×, sitemap), Responsive/Z-Index/Color (hex-exact) & 8-domain TypeScript Interfaces.
Discoverability: Matches nextjs16, react19, app-router, rsc, tailwind4, drizzle, postgres17, vitest, playwright, server-components, drizzle-kit, pg-pool, next-font, lucide-react. Use to scaffold, debug, onboard, or ship any similar full-stack marketing/funnel app without repeating 2026-09-11 incidents.
Companion Documents: Project_Architecture_Document.md v1.0 (blueprint) · CLAUDE.md (agent spec, ~600 lines) · AGENTS.md (cheat-sheet) · README.md (operator guide) · docs/REMEDIATION_PLAN_pass4.md (pass-4 evidence)
Last Updated: 2026-09-15 (v1.8 — pass 9 drift alignment D-01→D-07: STATIC_PATHS 40→39, build 36+7/42+8→35+8, E2E 61/82→121, radii 1.25/1.5→0.625/0.75/1.0, MORE 4→2, blur-md→lg; validation docs/VALIDATION_REPORT_SKILL_2026-09-15.md 11 drifts patched. Pass 8: audit remediation .env hygiene + vitest 4.1.11; pass 7: live-source parity; pass 6: audit)
Project State: 41 Vitest (11+13+9+8, incl. H4/OOM regression) + 121 Playwright per project (121/121 with DB, 120/121 DB-less — 103 declarations + data-driven loops: smoke 8 + seo 16 + funnel 5 + assets 19 + parity 73) · 43/43 Next build · PG 17 8/40/50/23/59/5 seeded · lint 0/0 · typecheck pass
Audience: AI Coding Agents, Senior Engineers, Tech Leads, DevOps, Onboarding Engineers
Rule: Every rule in this document traces to a specific file, test, or live probe. Nothing is here "because it's popular."
How to use this document
- New agent / engineer → Read §1 (Identity) → §2 (Stack) → §3 (Bootstrap) → §5 (Architecture + Golden Rule) → §11 (Pre-Ship) then run
npm run db:setup && npm run lint && npm run typecheck && npm run test && npm run build && npm run e2e.- Fixing a bug → Jump to §9 (Anti-Patterns) → §10 (Debugging Guide) → §12 (Lessons). Every anti-pattern has a test that prevents recurrence.
- Adding a feature → §5 (Layer Model + Component Map) → §7 (Content) → §15 (Patterns) → §20 (Interfaces). Don't bypass the layer.
- Shipping → §11 (Pre-Ship Checklist) is the gate. No
any, no secondPool, notailwind.config.*, no DB hand-edit must survive it.- Onboarding a team → Distribute this file + PAD +
AGENTS.md. PAD is the topology/ADR authority; this SKILL is the negative space — what not to do, how to debug, how to avoid repeating 2026-09-11 incidents.
Table of Contents
- Project Identity & Design Philosophy
- Tech Stack & Environment
- Bootstrapping & Configuration
- The Design System (Code-First)
- Component Architecture & Patterns
- Custom Hooks Deep Dive
- Content Management & Data Ingestion
- Accessibility (WCAG AAA) Implementation
- Anti-Patterns & Common Bugs
- Debugging Guide
- Pre-Ship Checklist
- Lessons Learnt & How to Avoid Them
- Pitfalls to Avoid
- Best Practices
- Coding Patterns
- Coding Anti-Patterns
- Responsive Breakpoint Reference
- Z-Index Layer Map
- Color Reference (Complete)
- The Complete TypeScript Interface Reference
- Appendix A: Architecture Decision Records
- Appendix B: Pipeline Costs
- Appendix C: Audit History
- Appendix D: Live-Site Validation & Quick Reference Card
- Appendix E: The Meticulous Approach (6-Phase Workflow)
1. Project Identity & Design Philosophy
One-sentence identity: ModFii (home-financing, legacy nextjs-postgresql-template) is a content + transaction hybrid for prefab homebuyers — SEO-driven editorial (23 articles, 50 state guides, 40 manufacturers) plus a single 13-field pre-qual funnel (12 required + optional manufacturerSlug) (/get-started → validateApplication → matchLenders → applications + top-4 application_matches) that closes the loop on modular/manufactured/ADU/tiny-home financing where traditional lenders fail.
Design thesis — Editorial + Brutalist Restraint (Luxury-Dark Cinematic without purple slop):
| Tenet | Expression in Code |
|---|---|
| Intentional minimalism — whitespace as structure, not emptiness | Container max-w-[1400px] mx-auto px-4 md:px-8 + PageHero centered max-w-4xl + GuideView grid lg:grid-cols-[1fr_280px] (280px h-fit aside — flows with the page, not sticky). No undifferentiated card grids. |
| Bespoke typography — one display + one body, tight tracking | Outfit (display, var(--font-outfit), letter-spacing:-0.03em on h1–h4/.font-display) + DM Sans (body, var(--font-dm-sans)) via next/font/google variable+swap. Never Inter/Roboto fallback without hierarchy. |
| Deep forest + warm cream + amber accent — 155-hue greens, not teal | Tokens in src/app/globals.css:@theme only (see §4). bg-forest (hsl 155 42% 16%) for header/hero, bg-background (hsl 40 33% 99%) warm cream, accent amber 38 92% 50% for CTA/highlight/star. |
| Photo as proof — 5 hero images are load-bearing | public/images/{hero-prefab,green-home,interior-living,adu-backyard,tiny-home}.jpg + brand/og-image.jpg behind PageHero forest overlay (opacity-35 + from-forest/80 via-forest/85 to-forest/90 + radial-gradient(38 92% 50% / 0.16)). 2026-09-11 incident when they were missing (hero rendered photo-less) is pinned by assets.spec.ts. |
| Anti-generic mandate — what is explicitly rejected | No purple-gradient-on-white, no Inter/Roboto safety pair without font-display hierarchy, no tailwind.config.* (v4 CSS-first only), no left-aligned gradient heroes (must be centered photo-backed PageHero), no amber header CTA (forest pill only), no undifferentiated card grids. |
Non-negotiable rules:
- File corpus is the source of truth — edit
src/data/*.json+src/lib/lenders.ts, never PG rows. DB is a projection (ensureSeeded). - Single Pool —
globalThis.__arenaNextJsPostgresqlPoolonly. Nevernew Pool()inline. - Token-driven styling —
@themeonly. Notailwind.config.*, notext-[13px]arbitrary. - RSC by default —
"use client"only forsite-header,prequal-form,calculator-app. Never import Server → Client. - Prefinancing accuracy —
PMI_ANNUAL_RATE 0.0065and site-built1.15×are auditable constants (see §15).
CTA hierarchy: Primary (forest) for actions, secondary for hero first CTA, onPrimary (transparent white border) for hero second CTA over dark forest, accent (amber) only for GuideView sidebar closing CTA. No other amber CTA in header.
Does this prevent generic Bootstrap? Yes — the @theme lock, PageHero centered photo contract, forest/cream token discipline, and Container rhythm make generic components visually fail before build does.
2. Tech Stack & Environment
Pinned from package.json (package-lock.json is the lockfile, not pnpm) + docker-compose.yml + drizzle.config.*. No ranges.
| Layer | Technology | Version | Critical Note |
|---|---|---|---|
| Framework | Next.js (App Router, hybrid) | ^16.3.4 (16.3.x) |
App Router only (src/app/**); proxy.ts replaces middleware.ts (not present — proxy concerns at reverse proxy). Pinned with eslint-config-next ^16.3.4 core-web-vitals. images.unoptimized:true intentional (no sharp infra). |
| UI Runtime | React | ^19.3.0 |
Server Components by default; useId, useEffect cleanup, lucide-react interop. No class components. |
| Language | TypeScript | ^5.9.3 |
strict:true, skipLibCheck:true, isolatedModules:true, moduleResolution: bundler, target: ES2017, jsx: react-jsx, incremental:true. Alias @/* → ./src/*. Exclude skills (avoids z-ai-web-dev-sdk missing). as any banned. |
| Styling | Tailwind CSS (CSS-first @theme) |
^4.3.3 + @tailwindcss/postcss ^4.3.3 + postcss ^8.5.28 |
No tailwind.config.* — all tokens in src/app/globals.css:@theme (sole source). Container 1400px rhythm. Never text-[13px] escape. |
| Icons | lucide-react | ^1.44.0 |
All product icons (Star, ArrowRight, ChevronDown, Menu/X, Home/Building2/Leaf/Shield etc). LinkedInIcon local SVG (no brand set in lucide). |
| ORM | Drizzle ORM | ^0.45.2 |
pgTable + drizzle(pool) + onConflictDoNothing. Chosen over Prisma for SQL control + drizzle-kit generate diff→SQL. |
| ORM Tooling | Drizzle Kit | ^0.31.10 |
drizzle.config.ts primary (satisfies Config) + .json fallback — out: ./drizzle, strict/verbose, url: …@5434 runtime wins. drizzle/0000_amusing_thena.sql + 0001_sharp_stick.sql. |
| Driver | pg + Pool |
^8.23.0 + @types/pg ^8.23.1 |
Singleton via globalThis.__arenaNextJsPostgresqlPool — prevents HMR leak. DATABASE_URL throw if missing. |
| Database | PostgreSQL | 17-alpine (host 5434) |
home_financing_dev / home_financing_user / home_financing_secret on home_financing_data volume + home_financing_net bridge. Extensions pgcrypto 1.3 + pg_trgm 1.6 via init. Health pg_isready. |
| Fonts | next/font/google |
built-in | Outfit (--font-outfit) display + DM Sans (--font-dm-sans) body — variable + display:swap. |
| Env Loader | dotenv |
^17.4.2 |
import "dotenv/config" in src/scripts/*.ts for lifecycle scripts. |
| Type Support | @types/node, @types/react, @types/react-dom |
^22.20.2, ^19.3.0, ^19.3.0 |
Completes tsc --noEmit. |
| Scripting | tsx |
^4.23.13 |
Runs `src/scripts/migrate |
| Lint | ESLint + eslint-config-next |
^9.39.5 + ^16.3.4 |
Flat config eslint.config.mjs + defineConfig + globalIgnores(.next,out,build,next-env,skills,infrastructure). |
| Unit | Vitest | ^4.1.11 (pass-8 audit bump — GHSA-82fw-gwwq-j7x9 fix; vitest/config, node env, include: src/**/*.test.ts) |
Co-located src/lib/*.test.ts pure only (calculator 11 + matching 13 + rate-limit 9 + markdown 8 = 41). 41/41 green. |
| E2E | Playwright + @axe-core/playwright |
^1.63.0 + ^4.13.0 |
Prod npx next start --port 3002 (not dev), reuseExistingServer:true, chromium+webkit, x-forwarded-for isolation. 121 per project — 103 declarations + data-driven loops (242 with webkit, 121/121 with DB, 120/121 DB-less; playwright --list shows 122 listed but 121 executed). assets 19 runtime + parity 73 runtime + smoke 8 + seo 16 + funnel 5. |
| Package Manager | npm | package-lock.json |
Single app, no monorepo/turborepo. Gate: db:setup → lint → typecheck → test → build → e2e. |
3. Bootstrapping & Configuration
3.1 Scaffolding (what was, what to repeat)
This project was not create-next-app scaffolded in its current form — it evolved from nextjs-postgresql-template. To reproduce cleanly:
# If scaffolding a fresh sibling for comparison:
# npx create-next-app@16.3 home-financing-new --ts --eslint --tailwind --app --src-dir --import-alias "@/*"
# Then transplant: src/app/globals.css (@theme), src/db/*, src/lib/*, src/data/*.json, drizzle/*, next.config.ts redirects
For this repo, start from clone — scaffolding is already done. Don't run create-next-app here.
3.2 Dependency Install
npm install # package-lock.json, not pnpm — single app, no workspace
Runtime: next, react, react-dom, drizzle-orm, pg, lucide-react, dotenv · Dev: typescript, @types/*, eslint, eslint-config-next, tailwindcss, @tailwindcss/postcss, postcss, drizzle-kit, tsx, vitest, @playwright/test, @axe-core/playwright.
3.3 Critical Configuration Files
| File | Lines | Purpose & Non-Obvious Rule |
|---|---|---|
tsconfig.json |
~35 | strict:true, skipLibCheck:true, isolatedModules:true, target ES2017, jsx react-jsx, moduleResolution bundler, baseUrl:., paths @/*→./src/*, plugins:[next], include: ["**/*.ts","**/*.tsx","next-env.d.ts",".next/types/**"], exclude:[node_modules,skills]. skills/** excluded because operator-managed z-ai-web-dev-sdk would break typecheck. Never set allowJs:true. |
next.config.ts |
~25 | images.unoptimized:true intentional (no sharp); redirects() 11 entries (/loans/fha-va-usda-construction → /modular-home-financing/loan-options/*, /manufacturers* → /modular-home-financing/manufacturers*, /states/:state → /modular-home-financing/states/:state, /get-started-v2→/get-started temp, /playbook→/learn temp, 2 compare parity permanents). Invalid redirect → build fails — guard via assets.spec.ts. |
eslint.config.mjs |
~10 | defineConfig([...nextCoreWebVitals, globalIgnores([".next/**","out/**","build/**","next-env.d.ts","skills/**","infrastructure/**"])]) — flat config (not .eslintrc), skills/infrastructure operator-managed, excluded from lint. Keep flat; never revert. |
vitest.config.ts |
~15 | defineConfig({ resolve:{ alias:{"@": fileURLToPath(new URL("./src", import.meta.url))}}, test:{ environment:"node", include:["src/**/*.test.ts"]}}) — alias mirrors tsconfig, node env (no DOM). |
playwright.config.ts |
~40 | testDir:"./e2e", timeout 30000, expect 5000, fullyParallel:false, projects:[chromium(Desktop Chrome), webkit(Desktop Safari)], webServer: process.env.E2E_BASE_URL ? undefined : { command:"npx next start --port ${PORT}", url:baseURL, reuseExistingServer:true, timeout:90000 } — E2E_PORT=3002 default (3000 is taken by sibling scandihaven), prod next start validates shipped artifact. |
drizzle.config.ts |
~15 | Primary TS config satisfies Config — schema:"./src/db/schema.ts", out:"./drizzle", dialect:postgresql, dbCredentials.url: process.env.DATABASE_URL ?? "…@5434/home_financing_dev", strict:true, verbose:true — runtime DATABASE_URL wins. |
drizzle.config.json |
~12 | JSON fallback — same out/schema/dialect/url/strict/verbose; keep in sync after db:generate (both 5434). |
postcss.config.mjs |
~5 | tailwindcss via @tailwindcss/postcss — no custom plugins. |
docker-compose.yml |
~45 | postgres:17-alpine home_financing_postgres on host 5434 → 5432, home_financing_data volume, home_financing_net bridge, PGDATA /var/lib/postgresql/data/pgdata, restart unless-stopped, health pg_isready, init infrastructure/postgres/init/00-create-extensions.sql. |
src/app/globals.css |
~110 | @import "tailwindcss" + @theme (sole token source) + @layer base (focus/ selection) + @layer utilities (grain, hero-grid) + prefers-reduced-motion. |
3.4 Environment Variables
| Variable | Required | Description | Default / Generation |
|---|---|---|---|
DATABASE_URL |
Yes | PG connection — src/db/index.ts throws if missing |
postgresql://home_financing_user:home_financing_secret@localhost:5434/home_financing_dev |
BETTER_AUTH_SECRET |
Yes | Auth signing | openssl rand -base64 32 (never commit; d572d73 leak in history) |
BETTER_AUTH_URL |
Yes | Canonical public origin — localhost breaks prod Invalid origin |
http://localhost:3000 dev / https://modfii.jesspete.shop prod |
BETTER_AUTH_TRUSTED_ORIGINS |
No | Extra trusted origins | https://admin.example comma-separated |
NEXT_PUBLIC_SITE_URL |
Yes | metadataBase + OG + sitemap host |
same as BETTER_AUTH_URL canonical |
CRON_SECRET |
Yes | Jobs runner | openssl rand -hex 16 (ec16d809a08c91d24dbfdab7e7be4a99 in current dev .env) |
STRIPE_SECRET_KEY |
No | Stripe test/live | sk_test_set-me |
STRIPE_WEBHOOK_SECRET |
No | Webhook signing | whsec_set-me |
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY |
No | Stripe publishable | pk_test_set-me |
RESEND_API_KEY |
No | Email — unset → log transport | re_… |
EMAIL_FROM |
No | From header | ModFii <orders@modfii.example> |
AUTH_GOOGLE_ID / SECRET |
No | OAuth | — |
AUTH_APPLE_ID / SECRET |
No | OAuth | — |
FEATURE_* |
No | Flags on/off (also true/false, 1/0); unknown FEATURE_* fails fast at boot (when wired) |
FEATURE_TRADE=off etc |
DISABLE_IMAGE_OPTIMIZER |
No | 1 where sharp deadlocks (no turbo.json exists in this repo — wire it into CI env instead if Turborepo is ever added) |
1 |
.envis.gitignored (ec11541untracked it afterd572d73leak). Nevergit add -f .env.docs/bak.env/env.tgz/ssh-key.txtalso ignored — don't reintroduce.
3.5 Local Setup (Minimal → Full)
# 1 — Clone & install
git clone <repo-url> home-financing && cd home-financing
npm install
# 2 — Env (copy then fill required — DATABASE_URL already canonical for Docker)
cp .env.example .env
# Generate if you rotate:
# openssl rand -base64 32 # BETTER_AUTH_SECRET
# openssl rand -hex 16 # CRON_SECRET
# 3 — DB (Docker simplest; 5434 intentional — 3000 is taken by scandihaven on this host)
docker compose up -d
docker compose logs -f postgres # expect pgcrypto: t / pg_trgm: t
# 3b — One-shot init (idempotent, local-guarded)
npm run db:setup # → [db] migrations applied (0000+0001) + [db] seed complete (8/40/50/23/59/5)
# Granular: npm run db:generate / db:migrate / db:seed / db:reset
# 4 — Run
npm run dev # http://localhost:3000 (if free)
# or
npx next dev --port 3002 # http://localhost:3002 (E2E default)
npm run build && npm start # prod preview
# 5 — Verify
curl -s http://localhost:3002/api/health | jq # { ok:true, status:ok, db:true }
curl -s http://localhost:3002/sitemap.xml | grep -o "<loc>" |wc -l # 152
npm run lint && npm run typecheck && npm run test && npm run build && npm run e2e
Alternative PG17 (no Docker): DATABASE_URL=postgresql://user:pass@localhost:5432/home_financing_dev npm run dev.
4. The Design System (Code-First)
Single source: src/app/globals.css — never add tailwind.config.*. Every token traced to @theme.
4.1 The @theme Block (Verbatim Evidence)
/* src/app/globals.css — verified against the 1,140-line Project_Architecture_Document.md; home-financing_SKILL.md is this file */
@import "tailwindcss";
@theme {
--font-sans: var(--font-dm-sans), ui-sans-serif, system-ui, sans-serif;
--font-display: var(--font-outfit), ui-sans-serif, system-ui, sans-serif;
--color-background: hsl(40 33% 99%); /* warm cream */
--color-foreground: hsl(155 30% 12%); /* forest text */
--color-card: hsl(40 25% 97%);
--color-card-foreground: hsl(155 30% 12%);
--color-primary: hsl(155 45% 28%); /* primary forest */
--color-primary-foreground: hsl(40 33% 99%);
--color-primary-600: hsl(155 50% 22%); /* hover */
--color-secondary: hsl(150 20% 92%);
--color-secondary-foreground: hsl(155 30% 12%);
--color-muted: hsl(150 15% 93%);
--color-muted-foreground: hsl(155 10% 45%);
--color-accent: hsl(38 92% 50%); /* amber CTA */
--color-accent-foreground: hsl(38 95% 12%);
--color-destructive: hsl(0 72% 51%);
--color-border: hsl(150 15% 88%);
--color-input: hsl(150 15% 88%);
--color-ring: hsl(155 45% 28%);
--color-cream: hsl(40 40% 96%); /* section wash */
--color-forest: hsl(155 42% 16%); /* deep forest — header/hero */
--color-moss: hsl(150 22% 34%); /* muted label */
--radius-sm: 0.5rem; --radius-md: 0.625rem; --radius-lg: 0.75rem; --radius-xl: 0.75rem; --radius-2xl: 1rem; /* pass-7 probe: rounded-md 10px / xl 12px / 2xl 16px (sm 8px) */
--shadow-lift: 0 18px 40px -24px hsl(155 30% 12% / 0.35);
--ease-brand: cubic-bezier(0.22,1,0.36,1);
}
@layer base { *{border-color:var(--color-border)} html{scroll-behavior:smooth}
body{background:var(--color-background);color:var(--color-foreground);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
h1,h2,h3,h4,.font-display{font-family:var(--font-display);letter-spacing:-0.03em}
:focus-visible{outline:2px solid var(--color-ring);outline-offset:3px}
::selection{background:hsl(38 92% 50% / 0.28);color:hsl(155 30% 12%)}
}
@layer utilities { .grain{position:relative}.grain::after{content:"";position:absolute;inset:0;opacity:.08;mix-blend-mode:multiply;background-image:url("data:image/svg+xml;utf8,<svg ...><feTurbulence baseFrequency='0.85' numOctaves='2'/></svg>")} .hero-grid{background-image:linear-gradient(40deg/0.04) 48px} }
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,*::before,*::after{animation-duration:.01ms!important;transition-duration:.01ms!important}}
If this block drifts, every assets.spec.ts + PageHero test conceptually fails — brand token authority is the lint.
4.2 Typography Hierarchy
| Role | Token | Source | Spec |
|---|---|---|---|
| Display / H1-H4 | var(--font-outfit) → .font-display |
next/font/google Outfit({ variable:"--font-outfit", display:"swap" }) in layout.tsx |
h1,h2,h3,h4 + .font-display → Outfit, letter-spacing:-0.03em, font-bold |
| Body / UI | var(--font-dm-sans) → .font-sans |
DM_Sans({ variable:"--font-dm-sans", display:"swap" }) |
body default, nav, form, prose |
| Hero H1 | font-display text-4xl md:text-6xl leading-[1.08] |
page.tsx |
Tight leading, highlight amber span text-accent |
| Section H2 | font-display text-2xl font-bold |
page-shell.tsx GuideView |
text-foreground |
4.3 Border Radii & Shadows
| Token | Value | Where Used |
|---|---|---|
sm |
0.5rem |
Badge rounded-full, Button rounded-md |
md |
0.625rem |
rounded-md = 10px (pass-7: --radius: .75rem → calc(-2px)) |
lg |
0.75rem |
--radius base |
xl |
0.75rem |
PageHero stat chips rounded-xl = 12px |
2xl |
1rem |
GuideView aside rounded-2xl = 16px |
--shadow-lift |
0 18px 40px -24px hsl(155 30% 12% / 0.35) |
Defined but currently unused — the header MORE dropdown hardcodes the equivalent arbitrary shadow shadow-[0_18px_40px_-24px_hsl(155_30%_12%_/_0.35)] |
--ease-brand |
cubic-bezier(0.22,1,0.36,1) |
<details> accordion ::details-content expansion (added 2026-09-11) and Reveal-adjacent motion |
4.4 Utilities
| Utility | Purpose | Where |
|---|---|---|
.grain |
SVG turbulence overlay (feTurbulence baseFrequency 0.85, opacity .08, multiply) |
Available for hero texture (not on every section) |
.hero-grid |
48px grid (linear-gradient 0.04 on both axes) |
Available for decorative grid |
5. Component Architecture & Patterns
5.1 The 5-Layer Model + Golden Rule
Layer 0: File Corpus — Authoring. Rule: Edit src/data/*.json + src/lib/lenders.ts. Never hand-edit PG.
Layer 1: Catalog — Typing. Rule: src/lib/catalog.ts re-exports JSON as Manufacturer/StateGuide/Article/GlossaryTerm; UI imports only from @/lib/catalog and @/lib/guides.
Layer 2: Projection — Seeding. Rule: src/lib/ensure-seeded.ts is the only write path; __modfiiSeedPromise + count(lenders)>0 + onConflictDoNothing; called in /api/health + /api/applications.
Layer 3: Persistence — Storage. Rule: src/db/index.ts Pool singleton __arenaNextJsPostgresqlPool; never new Pool() inline; db is drizzle(pool).
Layer 4: Application — Rendering. Rule: Server Components by default; "use client" only for site-header, prequal-form, calculator-app; never Server→Client; force-dynamic only where DB touched.
Layer 5: Edge — Routing. Rule: Redirects live in next.config.ts:redirects(); add aliases there only; build validates, e2e/assets pins.
Import boundary: src/components/ui.tsx (cn, Button/ButtonLink/Container/Badge) may be imported anywhere. src/lib/catalog.ts may be imported in RSC or Route Handlers. src/db/index.ts may only be imported server-side. No leaf component imports @/db from a "use client" file — build would throw RSC boundary.
5.2 Directory Map (with Counts)
src/app/** (43 pages: 35 static + 8 dynamic — _not-found, robots, sitemap + 3 ƒ API routes [health,applications,calculator] + 5 ƒ pages [authors/[authorSlug], learn/[slug], manufacturers/[slug], states/[state], debug-error-probe] + 35 static content pages)
├── layout.tsx (fonts + metadataBase + SiteHeader/Footer)
├── globals.css (@theme sole source)
├── page.tsx (~500 lines, editorial homepage)
├── sitemap.ts / robots.ts / not-found.tsx
├── api/{health,applications,calculator}/route.ts (3× force-dynamic)
├── get-started/page.tsx + calculator/page.tsx
├── modular-home-financing/** (cost, rates, down-payment, with-land/without-land, loan-options/{fha,va,usda,construction-loan}, manufacturers, states, ...)
├── compare/{fha-vs-conventional-prefab, prefab-vs-site-built-costs, modular-vs-manufactured-financing}
├── construction-loans/{fha,va,usda} + learn/[slug] + authors/[authorSlug] + glossary + resources + financing/mortgage/adu-financing/tiny-home-financing
src/components/ (9 files, 5 client leaves + error boundary = 6 use-client)
├── ui.tsx (primitives — allowed everywhere)
├── site-header.tsx ("use client") ← fixed h-16 md:h-20 always-light frosted bar bg-background/80 backdrop-blur-lg border-border/50 (pass 3 removed transparent hero, pass-7 md:h-20 + md:flex gap-8), NAV 4 + MORE 2
├── site-footer.tsx (LinkedInIcon local SVG)
├── page-shell.tsx (Breadcrumbs + PageHero + GuideView — interior heroes must use this)
├── prequal-form.tsx ("use client") ← 13-field funnel
├── learn-explorer.tsx ("use client") ← learn hub search/filter/featured/tools/newsletter
├── reveal.tsx ("use client") ← home intro scroll-reveal
├── calculator-app.tsx ("use client")
└── guide-screen.tsx (renderer via guides.ts + markdown.tsx)
src/data/ (4 JSON → 152 sitemap locs after projection)
├── articles.json (553 lines, 23 articles)
├── manufacturers.json (912 lines, 40)
├── states.json (1069 lines, 50)
└── glossary.json (237 lines, 59)
src/db/ (2 files)
├── schema.ts (8 pgTables, ~130 lines)
└── index.ts (~20 lines, singleton)
src/lib/ (7 domains + 3 co-located *.test.ts)
├── catalog.ts (180 lines) ← typed JSON re-export
├── lenders.ts (120 lines) ← LENDER_SEEDS 8 + LOAN_PRODUCT_SEEDS 5
├── ensure-seeded.ts (80 lines) ← idempotent projection
├── calculator.ts (90 lines) ← PMI 0.65% + 1.15×
├── matching.ts (130 lines) ← scoring + validation
├── rate-limit.ts (25 lines) ← Map buckets
├── guides.ts + markdown.tsx
├── calculator.test.ts (11 tests)
├── matching.test.ts (13 tests)
├── rate-limit.test.ts (9 tests)
└── markdown.test.ts (8 tests)
src/scripts/ (3 lifecycle, all local-guarded)
├── local-db.ts (isLocalDatabaseUrl / assertLocalDatabase)
├── migrate.ts (drizzle-orm migrator, ./drizzle)
├── seed.ts (ensureSeeded wrapper)
└── reset.ts (DROP SCHEMA public,drizzle CASCADE + extensions)
drizzle/ (0000_amusing_thena.sql ~200 lines, 0001_sharp_stick.sql 1 line, meta/_journal.json idx 0,1)
e2e/ (121 per project — 103 declarations + data-driven loops — --list 122→121, `parity.spec.ts` pins H4/OOM + parity + pass-5/7 live-source pins + pass-4 source-exact hero/guide-hero pins)
├── smoke.spec.ts (8) — home/nav/footer, get-started, calculator, health, 404, axe critical, security headers
├── seo.spec.ts (16) — sitemap + robots + title/OG + 11 source title-pattern pins
├── funnel.spec.ts (5) — POST 400/200, x-forwarded-for isolation, burst 429, DB-outage 500
├── assets.spec.ts (19 runtime — 14 image × 200 + 3 broken-img + 2 alias)
└── parity.spec.ts (73 — H4/OOM regression + visual-parity pins + pass-3/4/5/7 live-source pins)
Counts proven: grep -r "'use client'" src --include="*.tsx" | wc -l → 6 islands (site-header, prequal-form, calculator-app, learn-explorer, reveal, error.tsx). find src/components -name "*.tsx" | wc -l → 9. find src -name "*.test.ts" | wc -l → 4 (calculator 11 + matching 13 + rate-limit 9 + markdown 8 = 41). npx playwright test --list --project=chromium → 121 per project (--list shows 122 listed but 121 executed; 103 decl). wc -l src/data/* → 2771.
5.3 Client vs. Server Component Decision Tree
Need useState / useEffect / onClick / browser API ?
├── Yes → "use client" leaf (site-header, prequal-form, calculator-app only).
│ Never import a Server Component into it — extract shared UI to a leaf.
│ Example: site-header.tsx useId + useEffect(scroll passive) + useEffect(body overflow) + useState(prevPathname) pattern.
└── No → Server Component (default). Direct db / ensureSeeded() / catalog reads allowed.
Example: page.tsx, modular-home-financing/page.tsx, page-shell.tsx:PageHero, guide-screen.tsx, layout.tsx.
Need DB ? → Route Handler with export const dynamic = "force-dynamic" (applications, health, calculator).
Never add export const revalidate to a seeded page without understanding — it would stale ensureSeeded().
5.4 CTA & Layout Primitives
| Primitive | Spec | Enforcement |
|---|---|---|
Button variant |
primary/secondary/accent/outline/ghost/onPrimary, sm/md/lg |
variants: Record<ButtonVariant,string> in ui.tsx — no custom style prop |
Container |
mx-auto w-full max-w-[1400px] px-4 md:px-8 |
Header h-16 uses it; GuideView grid lg:grid-cols-[1fr_280px] uses it |
PageHero |
Centered max-w-4xl text-center, bg-forest + photo overlay + star pill + highlight amber + CTA pair + glass chips |
All GuideScreen pages flow through it; don't reintroduce left-aligned gradient heroes |
Breadcrumbs |
/ separator, light maps to text-primary-foreground/70, center justify-center |
Used inside PageHero with light center |
6. Custom Hooks Deep Dive
There is no
src/hooks/folder in this repo. That's intentional. Hooks live inlined in the two"use client"components that need them. This section documents those inlined patterns so you don't extract them prematurely or miss cleanup.
6.1 Header Scroll + Drawer (src/components/site-header.tsx)
// src/components/site-header.tsx — Patterns: useId (a11y), adjust-during-render (no effect), passive scroll, body-lock cleanup
"use client";
import { useEffect, useId, useState } from "react";
import { usePathname } from "next/navigation";
export function SiteHeader() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const menuId = useId(); // a11y: aria-controls={menuId} on mobile button → div#menuId
// 1) Transparent over dark hero only on "/" until scroll>12px (modfii.com parity)
const overDarkHero = pathname === "/" && !scrolled;
// 2) Close menus on client-side navigation WITHOUT setState-in-effect lint
// React's "adjust state during render" pattern — NOT useEffect(() => setOpen(false), [pathname])
const [prevPathname, setPrevPathname] = useState(pathname);
if (prevPathname !== pathname) {
setPrevPathname(pathname);
setOpen(false);
setMoreOpen(false);
}
// 3) Passive scroll listener — never block main thread
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 12);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true }); // passive:true — WHY: scrolling must stay 60fps
return () => window.removeEventListener("scroll", onScroll);
}, []);
// 4) Body overflow lock — must cleanup or page stays locked after HMR/unmount
useEffect(() => {
document.body.style.overflow = open ? "hidden" : "";
return () => { document.body.style.overflow = ""; };
}, [open]);
// ... header renders with fixed inset-x-0 top-0 z-50 backdrop-blur-lg
}
Why each detail matters:
passive:true— prevents scroll blocking; Chrome will warn if omitted. Don't add{ passive:false }here.adjust-during-render(notuseEffect) —eslint-plugin-react-hooks/exhaustive-deps+setState-in-effectwould otherwise trigger cascading-renders lint (101aec3fixed this exact lint error via this pattern — see §12).useId()— stablearia-controlsfor mobileid={menuId}; don't useMath.random().body overflowcleanup — returning""in cleanup prevents the drawer from leavingoverflow:hiddenafteropentoggles or component unmounts (test manually or via PlaywrightmoreOpenclick).
SSR safety: All window access is inside useEffect (never during render). Don't move window.scrollY to render scope — it would SSR-break.
6.2 Link vs. Button Navigation
site-header.tsx uses <Link href={item.href}> for nav and ButtonLink (which is <Link>) for Get Started. Don't use <a href> — it bypasses Next App Router client nav. Don't use router.push for static nav links.
6.3 When to Extract a Hook
Only extract when a third consumer appears. If you add a useScrollY(threshold) or useBodyLock(open) hook, it must preserve passive:true and the "" cleanup contract. Until then, keep them inlined — premature abstraction hides the 12px threshold.
7. Content Management & Data Ingestion
7.1 Where Content Lives (Source of Truth)
| File | Lines | Rows | Shape | Typed As | Seeded To |
|---|---|---|---|---|---|
src/data/articles.json |
553 | 23 | { slug, title, description, category, readTime, tag?, publishedAt, updatedAt?, authorName, authorRole, content (markdown), keywords, relatedSlugs } |
Article (src/lib/catalog.ts) |
articles table (slug varchar(160) unique) |
src/data/manufacturers.json |
912 | 40 | { slug, name, description, headquarters, founded (8→32), priceRange, homeTypes[], features[] } |
Manufacturer + computed `category: Affordable |
Mid-Range |
src/data/states.json |
1069 | 50 | { slug, name, abbreviation, lendingClimate, lendingDescription, medianHomePrice, averageLoanAmount, prefabMarketGrowth, popularAreas[], topManufacturers[] } |
StateGuide |
states table |
src/data/glossary.json |
237 | 59 | { term, definition } |
GlossaryTerm (letter derived term.charAt(0).toUpperCase()) |
glossary_terms table |
src/lib/lenders.ts |
120 | 8 + 5 | LENDER_SEEDS: { slug, name, description, specialties[], minCredit, greenMortgage, avgApprovalDays, rateDiscountBps, nmlsId } + LOAN_PRODUCT_SEEDS: { slug, name, downPayment, creditMin, summary, bestFor } |
LenderSeed |
lenders + loan_products |
Counts proven: python3 -c "len(json.load(open(...)))" + psql count(*) after db:seed → 8/40/50/23/59/5 (see PAD §4.2). articles.slug is varchar(160) for long editorial slugs; manufacturers.founded migrated 8→32 in 0001_sharp_stick.sql.
7.2 The Ingestion Pipeline (Projection, Not CMS)
src/data/*.json ─┐
├─► src/lib/catalog.ts (typed re-export, priceFloor/categorize, authors/AUTHOR_BIOS, authorSlug())
src/lib/lenders.ts┘ │
▼
src/lib/ensure-seeded.ts (global promise + count(lenders)>0 + onConflictDoNothing per slug/term)
│ idempotent, auto-runs in GET /api/health + POST /api/applications
▼
PostgreSQL 17 (drizzle pgTable, 8 tables, drizzle(pool) via __arenaNextJsPostgresqlPool)
│ onConflictDoNothing per unique slug/term
▼
src/app/** RSC + Route Handlers (read via catalog helpers: getManufacturer/getState/getArticle + drizzle(eq))
Why not import.meta.glob? Next.js App Router doesn't need Vite glob — catalog.ts is a typed import ... from "@/data/*.json" (with tsconfig resolveJsonModule:true) + computed category/letter. Glob would hide the priceFloor/categorize logic and break sitemap.ts's articles.map(article → /learn/${article.slug}) derivation.
7.3 How to Add Content (Procedure)
Add a manufacturer:
- Edit
src/data/manufacturers.json— append{ slug, name, description, headquarters, founded, priceRange, homeTypes, features }(slugvarchar(80)unique, no/). - Run
npm run db:seed(idempotent) —manufacturersonConflictDoNothing({ target: slug })will insert only the new row. - If manufacturing has been made more intricate, vary the articles and the glossary to directly mirror crafted / spelled.
Add a state / article / glossary term: Same — edit the JSON, npm run db:seed. Articles require authorName/authorRole → catalog.ts auto-derives authors + authorSlug; add to AUTHOR_BIOS if you want a custom bio (else generic).
Add a lender: Edit src/lib/lenders.ts LENDER_SEEDS, then npm run db:seed. LENDER_SEEDS 8 becomes 9 — E2E still expects lenders 8 in psql count(*) guard? No — assets.spec.ts doesn't pin lender count; only psql count(*) in PAD probes does. Update the probe expectation if you add.
Add a loan product: Same lenders.ts:LOAN_PRODUCT_SEEDS.
Rename a slug: You must add a redirect in next.config.ts:redirects() (see §3.3) and pin it in e2e/assets.spec.ts — build validates redirects but won't catch missing ones.
7.4 Locked Arrays & Regression Guards
| Array | Size | Guard | Why |
|---|---|---|---|
ARTICLE.keywords + relatedSlugs |
23 × variable | articles.map(k => k.keywords ?? []) in catalog.ts (default []) |
Sitemap priority 0.6 per article must not 500 on missing keywords |
glossaryByLetter() derived from term.charAt(0) |
59 → A–Z buckets | glossaryTerms table letter varchar(1) derived in ensure-seeded.ts |
Letter filter UI must not show empty bucket |
NAV 4 + MORE 2 in site-header.tsx |
8 total, [...NAV,...MORE] on mobile |
aria-label="Primary" / aria-label="Mobile" |
smoke.spec.ts: heading[1] + nav[Primary] visible |
8. Accessibility (WCAG AAA) Implementation
Contrast table — all pairs AAA except muted secondary (AA large-text only):
| Pair | Background | Foreground | Ratio | Level | Where |
|---|---|---|---|---|---|
| Page | background 40 33% 99% |
foreground 155 30% 12% |
~14.5:1 | AAA | body |
| Card | card 40 25% 97% |
card-foreground 155 30% 12% |
~13.8:1 | AAA | PageHero glass chips text-white on forest still ≥7:1 because forest is dark |
| Primary | primary 155 45% 28% |
primary-foreground 40 33% 99% |
~7.2:1 | AAA | Button primary, focus ring |
| Secondary | secondary 150 20% 92% |
secondary-foreground 155 30% 12% |
~12:1 | AAA | Button secondary |
| Muted | muted 150 15% 93% |
muted-foreground 155 10% 45% |
~4.6:1 | AA large only | Secondary prose — never body copy |
| Accent | accent 38 92% 50% |
accent-foreground 38 95% 12% |
~10:1 | AAA | Amber CTA text-accent-foreground on bg-accent |
| Hero | forest 155 42% 16% |
white / white/85 |
~12:1 / ~9:1 | AAA / AAA | PageHero title + description |
Focus ring (AAA):
/* src/app/globals.css @layer base — do not touch */
:focus-visible { outline: 2px solid var(--color-ring); outline-offset: 3px; }
ring is primary 155 45% 28% (dark forest), visible on background and card (both cream). Never override per-component.
Skip-to-content: Not yet implemented — add <a href="#main" class="sr-only focus:not-sr-only">Skip to content</a> at layout.tsx top and main id="main" when you touch the layout. Current e2e axe-core runs include("main") — it would still pass without the link but keyboard users need it.
prefers-reduced-motion:
@media(prefers-reduced-motion:reduce){
html{scroll-behavior:auto}
*,*::before,*::after{animation-duration:.01ms!important;transition-duration:.01ms!important}
}
Verified via globals.css — every transition-colors duration-300 (header) collapses to 0.01ms when user prefers reduced motion.
Touch targets: Mobile site-header button h-10 w-10 (40px), NAV links px-3 py-3 on mobile (≥44px height). Don't shrink.
ARIA per component:
| Component | Pattern | Proven |
|---|---|---|
SiteHeader |
nav[aria-label="Primary"] + nav[aria-label="Mobile"], button[aria-expanded][aria-controls=menuId], aria-label toggle Open/Close menu |
smoke.spec.ts: getByRole("navigation", {name:"Primary"}) |
PageHero |
nav[aria-label="Breadcrumb"] with / aria-hidden |
page-shell.tsx — aria-hidden on separator |
GuideView |
details > summary FAQ with [&::-webkit-details-marker]:hidden + ChevronDown aria-hidden |
No aria-expanded on summary (native <details> semantics) |
Forms (prequal-form) |
onError inline alert + disabled during async + aria-label on icon buttons |
validateApplication strings surfaced inline |
Axe proof: e2e/smoke.spec.ts: AxeBuilder(include:"main").analyze().filter(impact==="critical") → [] — critical violations blocked on main.
9. Anti-Patterns & Common Bugs
Each entry: Symptom → Root cause → Fix → How to prevent recurrence (test/guard). Severity: CRITICAL (data loss/auth bypass) / HIGH (prod 404/500) / MEDIUM ( UX / contract) / LOW (tech debt).
#01 — Missing Hero Images (404, photo-less homepage) [HIGH]
- Symptom:
/renders without photos; green band shows raw alt text. Six images404. - Root:
public/images/{hero-prefab,green-home,interior-living,adu-backyard,tiny-home}.jpg+brand/og-image.jpgwere not committed;src/app/page.tsx+page-shell.tsx:PageHeroreferenced them. - Fix:
c74dd7ecommitted 6 images +og-image.jpg; adoptedmodfii-logo-icon.svgcanonical. - Guard:
e2e/assets.spec.ts— 14request.get(src) → 200(heroes/OG + 5public/brand/wordmarks/*.png+ 3public/images/avatars/*.jpg) +no broken <img> (naturalWidth===0)on/,/adu-financing,/tiny-home-financing;buildwould not catch it (images are static).
#02 — Source-Parity Compare Aliases 404 [HIGH]
- Symptom:
modfii.comfooter links/compare/fha-vs-conventional+/compare/prefab-vs-site-built404'd; clone only shipped-prefab/-costsvariants. - Root:
next.config.ts:redirects()missing 2 entries. - Fix: Added
permanent:truealiases:fha-vs-conventional → fha-vs-conventional-prefab,prefab-vs-site-built → prefab-vs-site-built-costs. - Guard:
e2e/assets.spec.tsrequest.get(alias,{maxRedirects:5}) → 200;buildvalidates redirect syntax.
#03 — Second Pool → HMR EMFILE / Auth Leak [CRITICAL]
- Symptom:
EMFILE: too many open filesindevHMR; or twoPoolconfigs diverge (one5434, one5432legacy). - Root:
new Pool()outsidesrc/db/index.ts. - Fix: Singleton via
globalThis.__arenaNextJsPostgresqlPoolinsrc/db/index.ts;rg "new Pool"must return single hit. - Guard: Grep guard +
drizzle.config.*both5434;local-db.tsrefuses non-local.
#04 — setState in Effect Lints react-hooks/set-state-in-effect [MEDIUM]
- Symptom:
npm run lintfailed101aec3-era: header menus were reset viauseEffect(() => setOpen(false), [pathname])→ cascading renders. - Root: ESLint
core-web-vitalsflags it. - Fix:
4d1e6ad— adjust state during render:if(prevPathname!==pathname){ setPrevPathname(pathname); setOpen(false); setMoreOpen(false); }insite-header.tsx. - Guard:
npm run lint0/0; lint error would block PR.
#05 — .env Committed with Real Secrets (d572d73) [CRITICAL]
- Symptom:
BETTER_AUTH_SECRET+CRON_SECRETvisible viagit show d572d73:.env. - Root:
.envwas tracked before.gitignorerule;ec11541later untracked it (git rm --cached .env). - Fix: Rotate secrets on deploy; current dev
.envhas freshec16d809a08c91d24dbfdab7e7be4a99(CRON_SECRET). - Guard:
.gitignorenow covers.env,bak.env,env.tgz,ssh-key.txt;git log --all -p -- .envwould resurrect old value — runfilter-repo/BFG to scrub if deploy used it as prod.
#06 — Direct PG Hand-Edit Clobbered on Fresh Seed [MEDIUM]
- Symptom: Hand-edited
lendersrow viapsql UPDATEvanished afterdocker compose down -v && db:setup. - Root: DB is projection; file corpus is source of truth.
- Fix: Edit
src/data/*.json+src/lib/lenders.ts, thennpm run db:seed. - Guard: Documented in
AGENTS.md+CLAUDE.md+ this SKILL§5 Layer 0;ensureSeededoverwrites on empty, not on populated (so non-empty is safe, but fresh volume resets).
#07 — tailwind.config.* Drift (v3 vs v4 CSS-First) [MEDIUM]
- Symptom: Arbitrary
text-[13px]+tailwind.config.jsreintroduced tokens in two places. - Root: Copy-paste from v3 templates.
- Fix: Delete
tailwind.config.*; extend only@themeinglobals.css. - Guard:
ls tailwind.config*must be empty; PR review rejects arbitrary values.
#08 — as any / @ts-ignore Erases Strict Guards [MEDIUM]
- Symptom:
tscpasses but runtime breaks onApplicationInputshape. - Root:
as anyto silenceparseBodyunion. - Fix:
unknown+asString(record.field)+typeof === "string"narrowing;as constonLOAN_PRODUCT_SEEDS. - Guard:
typecheck+ PR ruleas any → Never;grep -rn "as any" srcmust be0.
#09 — Server→Client Import Boundary Violation [HIGH]
- Symptom:
next buildthrowsCannot import Server Component into Client Component. - Root:
import { PageHero } from "@/components/page-shell"from"use client"file (orimport { db } from "@/db"in client). - Fix: Extract shared UI to a leaf with no server import; keep
util: cnetc. inui.tsxwhich stays boundary-clean. - Guard:
buildis the gate — it fails fast.
#10 — In-Memory Rate Limiter Under-Limits on Multi-Instance [HIGH]
- Symptom: Burst not throttled after horizontal scale (each instance has its own
Map). - Root:
src/lib/rate-limit.tsis in-memory by design for single deploy. - Fix: Migrate to Redis/Upstash before scaling; document
ENV FEATURE_RATELIMIT=rediswhen introduced. - Guard:
funnel.spec.tsusesx-forwarded-for: burst-*isolation; test would flip to 2-instance Redis test when scaling.
#11 — Broken <img> Without <Image> Priority [LOW]
- Symptom: LCP image (
hero-prefab.jpg) lazy-loaded, poor Web Vitals. - Root:
<img>tag instead ofnext/image. - Fix: Use
next/imageeven withunoptimized:true— still givespriority,sizes,altenforcement. - Guard:
assets.spec.tsnaturalWidth===0check on key pages.
#12 — N+1 Lender Lookup at Scale [LOW]
- Symptom:
POST /api/applicationsfetches lender byeq(slug)in a loop (4× today). If match count grew,N+1. - Root: Loop pattern fine at
4, wasteful at40. - Fix: Batch via
whereIn(lenders.slug, matchSlugs)when fanout grows; current 4 is explicitly acceptable perCLAUDE.md. - Guard: Code comment in §15 pattern notes the scaling point.
#13 — DISABLE_IMAGE_OPTIMIZER Without turbo.json [LOW]
- Symptom: Sandbox doc claims
must stay in turbo.json:globalEnvbutturbo.jsondoesn't exist. - Root: Monorepo
scandihavenpattern copied into single-app doc. - Fix: Either add
turbo.jsonwhen Turborepo introduced or drop the claim. - Guard: Verify at PAD update time.
#14 — Manual src/lib/catalog.ts Drift (Stale authorSlug / letter) [MEDIUM]
- Symptom: New
articles.jsonentry withauthorName: "Jane Doe"createsauthorSlug: jane-doebutglossaryTerms.letterstill hardcodedletter: "J"vs derivedterm.charAt(0)mismatch. - Root:
ensure-seeded.tsderivesletterbut older manual DB edit wrote literal. - Fix: Never hand-edit PG; trust
letter: item.term.charAt(0).toUpperCase()inensure-seeded.ts. - Guard:
psql select distinct letter from glossary_termsmust equalterm[0] upper.
10. Debugging Guide
Symptom → Root → Fix. Every entry was encountered in this repo's history (see §12/§C).
| Symptom | Root Cause | Fix |
|---|---|---|
Error: DATABASE_URL is required on boot |
Missing .env or DATABASE_URL unset |
cp .env.example .env + fill postgresql://home_financing_user:secret@localhost:5434/home_financing_dev |
ECONNREFUSED :5432 from drizzle-kit |
Stale drizzle.config.json with old 5432 (pre-scandihaven_* legacy) |
Both configs now 5434/home_financing_dev — keep them in sync after db:generate; runtime DATABASE_URL still wins |
skills/ TS2307 z-ai-web-dev-sdk |
skills/ excluded via tsconfig + eslint globalsIgnore, not installed |
Don't install z-ai-web-dev-sdk; don't re-include skills (operator contract) |
ENOTFOUND home-financing.jesspete.shop in E2E sitemap 30×200 |
sitemap.xml uses NEXT_PUBLIC_SITE_URL prod host |
seo.spec.ts host-rewrites loc.origin → E2E_BASE_URL (127.0.0.1:3002) before request.get — set NEXT_PUBLIC_SITE_URL=https://modfii.jesspete.shop on deploy |
429 persists across E2E tests |
In-memory Map (8/10min) persists under reuseExistingServer:true |
Use isolated x-forwarded-for: test-* per request; burst test uses dedicated burst-* IP (see funnel.spec.ts: burst-${Date.now()}) |
EADDRINUSE 3000 for E2E webServer |
Sibling scandihaven on :3000 |
E2E_PORT=3002 is the E2E default for this repo (playwright.config.ts: PORT=3002) |
react-hooks/set-state-in-effect lint error |
Header menus reset via useEffect on pathname |
Fixed via adjust-during-render in site-header.tsx — don't revert |
Invalid origin on auth |
BETTER_AUTH_URL still localhost in prod |
Set BETTER_AUTH_URL to canonical prod origin https://modfii.jesspete.shop + extras in BETTER_AUTH_TRUSTED_ORIGINS |
sharp deadlock in sandbox |
Image optimizer sharp native stress |
Keep images.unoptimized:true + DISABLE_IMAGE_OPTIMIZER=1 in constrained env; don't re-enable without infra |
.env committed / git show leaks secret |
.env was tracked before .gitignore |
Untracked ec11541; rotate BETTER_AUTH_SECRET + CRON_SECRET — they remain in d572d73 history |
Sitemap/OG emit home-financing.jesspete.shop |
NEXT_PUBLIC_SITE_URL stale host on deploy |
Set NEXT_PUBLIC_SITE_URL=https://modfii.jesspete.shop in deploy env |
Refusing to run against non-local host on migrate/seed |
DATABASE_URL points at staging/prod |
assertLocalDatabase guard — run migrations via CI's non-local path, not npm run db:* |
PageHero shows no photo / raw alt |
public/images/*.jpg missing (incident 2026-09-11) |
ls public/images/ → 5 + brand/og-image.jpg; assets.spec.ts pins 200 |
/compare/fha-vs-conventional 404 |
Parity alias missing | Add in next.config.ts:redirects() + pin in assets.spec.ts (permanent) |
npm run typecheck fails on skills/** |
skills not excluded |
tsconfig.json: exclude:[node_modules, skills] + eslint.config.mjs: globalIgnores(skills/**) must stay |
axe critical fails on / |
main landmark missing or focus-visible overwritten |
axe-core: include("main").analyze().filter(critical) → []; keep globals.css:focus-visible token |
seed inserted 0 rows on fresh DB |
ensureSeeded raced with empty lenders count cache |
Not possible — globalThis.__modfiiSeedPromise single-flights; second caller awaits first |
Triage flow (when unknown):
docker compose logs -f postgres # init extension notices + health
curl -s http://localhost:3002/api/health | jq # readiness + ensureSeeded probe
npm run typecheck # fastest regression signal
npm run build 2>&1 | tail -n 100 # RSC boundary + redirect validation
sudo docker exec home_financing_postgres psql -U home_financing_user -d home_financing_dev -c "select count(*) from lenders;"
11. Pre-Ship Checklist
Run in order — earlier gates catch cheaper failures. Every push must pass. Documented in AGENTS.md, CLAUDE.md, README.md gate table.
# 1 — DB is the projection, not the authoring store. One-shot, idempotent, local-guarded.
npm run db:setup
# → [db] migrations applied (0000+0001) + [db] seed complete (8/40/50/23/59/5)
# Granular: npm run db:generate / db:migrate / db:seed / db:reset
# 2 — Lint (flat config, core-web-vitals, skills+infrastructure excluded)
npm run lint
# → 0 errors / 0 warnings
npm run lint:fix # auto-fix when dirty
# 3 — Type (strict, skills excluded — z-ai-web-dev-sdk not installed)
npm run typecheck
# → 0 errors — no any, no ts-ignore
# 4 — Unit (pure domains only; DB paths stay under Playwright)
npm run test
# → 41/41 vitest (calculator 11 + matching 13 + rate-limit 9 + markdown 8 — incl. H4/OOM regression)
npm run test:watch # watch
npm run test:coverage # when wired
# 5 — Build (validates redirects, RSC boundaries, skills excluded)
npm run build
# → ✓ Compiled successfully in ~538ms
# → ✓ Generating static pages using 3 workers (43/43) in ~957ms
# → Routes: ○ 35 static + ƒ 8 dynamic
# 6 — E2E (prod next start on 3002, reuseExistingServer, chromium; valid-payload needs DB)
npm run e2e # chromium 121/121 per project (103 declarations + data-driven loops — --list 122→121; 120/121 DB-less)
npm run e2e:all # chromium + webkit
# 7 — Readiness + content surfaces
curl -s http://localhost:3002/api/health | jq
# → { "ok": true, "status": "ok", "db": true }
curl -s http://localhost:3002/sitemap.xml | grep -o "<loc>" | wc -l
# → 152 (39 STATIC + 23 learn + 50 states + 40 manufacturers)
# 8 — Manual asset/alias probe (what assets.spec.ts does automatically)
for p in /images/hero-prefab.jpg /images/green-home.jpg /images/interior-living.jpg /images/adu-backyard.jpg /images/tiny-home.jpg /brand/og-image.jpg; do echo "$p $(curl -s -o /dev/null -w "%{http_code}" http://localhost:3002$p)"; done
# → 6× 200
curl -I http://localhost:3002/compare/fha-vs-conventional # 308 → /compare/fha-vs-conventional-prefab
curl -I http://localhost:3002/compare/prefab-vs-site-built # 308 → /compare/prefab-vs-site-built-costs
Additional gates (run mentally or via script):
- Every new route has
loading.tsx/error.tsxor an ADR explaining why not - Rename of any
slughas anext.config.ts:redirects()entry (permanent) +assets.spec.tspin -
NEXT_PUBLIC_SITE_URL+BETTER_AUTH_URLon deploy =https://modfii.jesspete.shop -
grep -rn "as any" src→ empty;grep -rn "new Pool" src→ single hit insrc/db/index.ts -
ls tailwind.config*→ absent;ls public/images/*.jpg→ 5 +brand/og-image.jpgpresent -
BETTER_AUTH_SECRET/CRON_SECRETnot committed (git log --all -p -- .envshould not show fresh values) - Lighthouse/CWV + keyboard
focus-visible+aria-labelon icon buttons don't regress (axecritical: []onmain)
12. Lessons Learnt & How to Avoid Them
Each lesson traces to a specific code change or test. Numbered LL-01…
Sprint 1 — Bootstrap
LL-01. File-backed projection beats DB-as-source for editorial.
Content diffed in src/data/*.json survives docker compose down -v and PR review. ensureSeeded() idempotent via globalThis.__modfiiSeedPromise + count>0 + onConflictDoNothing. Avoided: hand-editing PG rows that vanish on fresh volume. Ref: src/lib/ensure-seeded.ts, AGENTS.md never-do #2.
LL-02. Pool singleton must be globalThis or HMR leaks Pool.
globalThis.__arenaNextJsPostgresqlPool prevents EMFILE on HMR. Never new Pool() inline — single import from @/db only. Fix: src/db/index.ts singleton + rg guard. Test: build would throw if second Pool leaked handles.
LL-03. CSP/HSTS/DENY were not optional — auth later.
Initial probe showed curl -v /api/health emitting content-security-policy, strict-transport-security, x-frame-options DENY. Don't weaken them when adding Stripe/Cloudflare scripts — extend connect-src / script-src narrowly. Ref: src/app/layout.tsx inherits Next's defaults + proxy headers.
LL-04. Keep drizzle.config.ts + .json in sync.
drizzle-kit generate writes via whichever config is resolved; mismatched 5434 vs 5432 once gave ECONNREFUSED :5432. Runtime DATABASE_URL still wins, but keep both 5434/home_financing_dev. Lesson: diff both after db:generate.
Sprint 2 — Security Remediation (2026-09-11 Incidents)
LL-05. .env with real secrets was tracked (d572d73).
BETTER_AUTH_SECRET + CRON_SECRET were committed before .gitignore added .env. ec11541 untracked (git rm --cached .env). Rotate both — they remain in git show d572d73:.env. Don't git add -f .env ever. Guard: grep "git show" .git/logs.
LL-06. skills/** must be excluded from typecheck/lint/build.
Operator-managed skills/ vendored JS br
Truncated - read the full file at https://github.com/nordeim/personalized-tutor-app/blob/ff8c5bdf5ab5c82bc84f41bba79731461824fbcd/skills/nextjs-postgresql-single-app/SKILL.md.
