Landing Page & Public Site -- 5 Whys Root Cause Analysis¶
Generated: 2026-02-25 | Method: 5 Whys Deep Dive (symptom -> why chain -> root cause -> systemic fix) Source Deep-Dive:
docs/deep-dive-landing-page-public-site.mdChains Analyzed: 6 symptom-to-root-cause chains covering broken caching, unsanitized HTML, zero test coverage, missing SEO, dead CTAs, and leaked console.log statements Analyst: Mary -- treasure hunter, follower of breadcrumbs, collector of smoking guns
Executive Summary¶
Six recurring failure patterns across 70 documented issues in the Landing Page & Public Site module trace back to 3 systemic root causes: absent automated quality gates, visual-demo-first development culture, and no security engineering discipline. These root causes are not independent -- they form a reinforcing triangle where each failure mode enables and conceals the other two. The good news? A single CI pipeline with 4 rules would have prevented 4 of 6 patterns entirely.
Chain A: Why Is unstable_cache Broken in ALL 5 Public API Functions?¶
Symptom: Every unstable_cache call in src/api/public/ uses a static cache key that ignores the function's parameters. getBlogs({ page: 2 }) returns page 1. getNavigationLinks("footer") returns navbar links. getSettingByGroup("contact") returns site settings. The entire public site's data layer is poisoned after the first cache population.
| Depth | Question | Answer |
|---|---|---|
| Why 1 | Why do all 5 functions return wrong data? | The unstable_cache second argument (key array) is static in every file: ["blogs"], ["blog-details"], ["dynamic-page"], ["settings-group"], ["navigation-links"]. Parameters like slug, type, group, and page are never included. The cache treats every invocation as the same request. |
| Why 2 | Why are the keys static across all 5 files? | The developer wrote navigation-links.js first, with a static key that happened to work (there is only one navbar, one footer, etc. -- except wait, there are THREE types, so it was broken from the start). Then the pattern was copy-pasted to the other 4 files without understanding why the key array exists. |
| Why 3 | Why was the pattern copy-pasted instead of abstracted? | No shared utility function exists. Each file reimplements unstable_cache from scratch: same import, same try/catch, same error handling, same cache options. The copy-paste evidence is forensic: settings.js lines 16 and 28 say "Failed to fetch navigation links" and "Error fetching navigation links" -- the error messages from navigation-links.js were never updated after copy-paste. The developer literally Ctrl+C'd navigation-links.js, changed the URL, and forgot to change the log messages AND the cache key logic. |
| Why 4 | Why did nobody catch this during code review? | There was no code review. The codebase shows no PR workflow, no review comments, no .github/CODEOWNERS, no branch protection configuration. Single-developer workflow with direct push to main. |
| Why 5 | Why no code review? | No automated quality gates of any kind. No CI pipeline, no pre-commit hooks, no linting rules for cache correctness. The ESLint config (eslint.config.mjs) extends only next/core-web-vitals -- a minimal ruleset that checks for accessibility basics and React hook rules. No custom rules. No Husky. No lint-staged. Without even a basic "does this build?" check, there is no forcing function for review. |
Root Cause: Copy-paste development without abstraction, enabled by total absence of code review and automated quality gates.
The Smoking Gun: settings.js:16 -- console.log("Failed to fetch navigation links"). This is the treasure-hunter's dream: a literal forensic fingerprint of copy-paste. The developer copied navigation-links.js to create settings.js, changed the fetch URL, but left the error messages AND the cache key pattern untouched. This single line proves the bug's propagation mechanism.
Pattern Name: "Clone and Forget" -- a working (or seemingly working) implementation is duplicated across files without understanding the invariants. Each clone inherits both the structure and the bugs.
Fix: Include parameters in cache keys (5 files, 1 line each, 30 min). Structural fix: Extract a shared cachedFetch(key, params, fetcher) utility so the cache-key-includes-params invariant is enforced once, not copy-pasted five times.
Chain B: Why Is dangerouslySetInnerHTML Used Without Sanitization?¶
Symptom: Raw HTML from the database is rendered directly into the DOM in 4 locations across the frontend -- 2 on public pages (blog content, dynamic page description) and 2 in the email template builder (template preview, template modal). No sanitization library is installed anywhere in the project.
| Depth | Question | Answer |
|---|---|---|
| Why 1 | Why is HTML rendered without sanitization? | blog-page-frontend.jsx:94 does dangerouslySetInnerHTML={{ __html: blog.content }} and dynamic-page.jsx:20 does dangerouslySetInnerHTML={{ __html: data?.description }}. Neither calls DOMPurify, sanitize-html, or any other sanitizer. The email-templates-page.jsx:805 and email-preview.jsx:288-297 do the same for template preview HTML. |
| Why 2 | Why is no sanitization library installed? | A grep for DOMPurify and sanitize-html across the entire frontend returns zero results. Neither package appears in package.json dependencies. The concept of HTML sanitization simply does not exist in this codebase. |
| Why 3 | Why was sanitization never considered? | The developer likely assumes "only admins create content, so it is trusted." This is the same super_admin-only development pattern found in the RBAC deep-dive and email template deep-dive. When you test as the omnipotent user, you never think about malicious input because YOU are the input source. |
| Why 4 | Why is there no ESLint rule to catch this? | The react/no-danger ESLint rule exists precisely for this purpose -- it flags every dangerouslySetInnerHTML usage for review. But the project's ESLint config contains only next/core-web-vitals. No security-focused lint rules are configured. No eslint-plugin-security. No custom rule enforcement. |
| Why 5 | Why no security linting? | No security engineering discipline. Security is treated as "the framework's job." Next.js auto-escapes JSX expressions, so the developer likely assumes all rendering is safe -- not realizing that dangerouslySetInnerHTML is the explicit escape hatch FROM that safety. The React API literally has "dangerous" in the name as a warning, but without a rule to enforce review, warnings are decorative. |
Root Cause: No security engineering discipline -- no sanitization libraries, no security-focused lint rules, no threat modeling during development. Combined with the "trusted admin" assumption that collapses under the documented self-role-change vulnerability (PATCH /users/profile allows privilege escalation).
The Attack Chain: The documented self-role-change vulnerability means an attacker can: register -> escalate to admin via profile update -> create blog post or dynamic page with <script>document.cookie</script> -> every public visitor executes the attacker's JavaScript. This is not theoretical -- it is 4 API calls.
Evidence Inventory (9 total dangerouslySetInnerHTML locations):
1. blog-page-frontend.jsx:94 -- blog content (PUBLIC, XSS vector)
2. dynamic-page.jsx:20 -- dynamic page description (PUBLIC, XSS vector)
3. email-templates-page.jsx:805 -- template HTML preview (ADMIN, lower risk)
4. email-preview.jsx:288 -- col1Content (ADMIN, lower risk)
5. email-preview.jsx:292 -- col2Content (ADMIN, lower risk)
6. email-preview.jsx:297 -- col3Content (ADMIN, lower risk)
7. pages-page.jsx:503 -- page description in admin list (ADMIN, lower risk)
8. utils.js:39 -- utility line renderer (CONTEXT DEPENDENT)
9. chart.jsx:66 -- chart tooltip (ADMIN, lower risk)
Fix: Install DOMPurify, create a <SafeHTML> wrapper component, use it in all 9 locations (2 hours). Structural fix: Add react/no-danger ESLint rule as error (not warn) so future usages are blocked at lint time.
Chain C: Why Is There Zero Test Coverage on the Entire Public Site?¶
Symptom: No test files exist anywhere in the frontend codebase. No test runner is installed. No test dependencies in package.json. No test script in package.json. Statement, branch, function, and line coverage: 0% across all 81 files and ~8,200 lines of code.
| Depth | Question | Answer |
|---|---|---|
| Why 1 | Why are there no tests? | A glob search for *.test.* and *.spec.* across the entire Sass-boilerplate-frontend-v1/ directory returns zero files. Not a single test exists -- not for components, not for API hooks, not for utilities, not for pages. |
| Why 2 | Why is no test runner installed? | package.json contains no reference to Jest, Vitest, Mocha, Testing Library, or any test framework. There is no jest.config.js, no vitest.config.ts, no test setup file of any kind. The test infrastructure was never initialized. |
| Why 3 | Why was test infrastructure never set up? | The project prioritizes feature breadth over feature depth. 81 files covering landing page, blog, contact, dynamic pages, navigation, email templates, and admin dashboard -- all built without a single test. The development approach is "ship features, not test them." |
| Why 4 | Why does the development approach skip testing? | No CI/CD pipeline exists. There is no GitHub Actions workflow, no pre-merge check, no deployment gate. Without a pipeline that REQUIRES passing tests before merge, there is no forcing function to write tests. Tests are a cost with no immediate visible benefit in a pipeline-less workflow. |
| Why 5 | Why no CI/CD pipeline? | Absent SDLC (Software Development Life Cycle). This is a single-developer project with no formal process: no branching strategy (direct push to main), no code review, no quality gates, no deployment automation. The development model is "edit file, push, manually check browser." Without an SDLC, testing is a voluntary discipline -- and voluntary discipline does not scale. |
Root Cause: Absent SDLC -- no CI/CD, no quality gates, no automated feedback loops. Testing requires infrastructure that enforces it; without that infrastructure, zero test coverage is the natural equilibrium.
What Zero Tests Enable (the cascade):
- Cache key bugs (Chain A) ship undetected -- a single test expect(getBlogs({page:1})).not.toEqual(getBlogs({page:2})) would catch C1-C5
- XSS vulnerabilities (Chain B) persist -- a test rendering <script>alert(1)</script> would verify sanitization
- SEO metadata gaps (Chain D) go unnoticed -- a test checking generateMetadata output would catch H2, M2-M4
- Dead CTAs (Chain E) are invisible -- a test asserting expect(button).toHaveAttribute('href') would catch H1
- console.log statements (Chain F) slip through -- a test or lint rule would flag them
The Damning Statistic: 81 files. 8,200+ lines of code. 70 documented issues. 0 tests. 0 test files. 0 test dependencies. 0%.
Fix: Initialize Vitest + Testing Library (1 hour setup). Write smoke tests for the 5 public API functions and 7 route pages (4 hours). Structural fix: CI pipeline that blocks merge on test failure (ADR-5 in the ADR document).
Chain D: Why Is SEO Metadata Missing on 4 of 7 Public Routes?¶
Symptom: Only 3 of 7 public routes have generateMetadata exports: /blogs, /blogs/[slug], /[slug]. The remaining 4 -- homepage (/), author (/author/[slug]), category (/category/[slug]), and contact (/contact) -- have no SEO metadata. The MOST important page (homepage) has the LEAST SEO optimization.
| Depth | Question | Answer |
|---|---|---|
| Why 1 | Why do only 3 routes have metadata? | The 3 routes with metadata are the ones where the backend already provides meta fields: Blog entity has metaTitle, metaDescription, metaKeywords; GenericPage entity has the same. The developer only added generateMetadata where the backend data model already had the fields to populate it. Blog listing (/blogs) has static metadata. |
| Why 2 | Why don't the other 4 routes have metadata? | Homepage is 100% static (no API call, all from constants) -- the developer likely assumed the root layout's generic metadata was sufficient. Author and category pages do not have dedicated backend entities with meta fields -- they derive from blog data. Contact page has no content model at all. Without a backend meta source, the developer did not create frontend metadata. |
| Why 3 | Why does the developer only add metadata when the backend provides it? | Metadata generation is treated as a backend-driven concern ("if the API gives me meta fields, I'll use them") rather than a product-level concern ("every public page MUST have proper SEO"). There is no product requirements document, no SEO checklist, no acceptance criteria that includes metadata. |
| Why 4 | Why is there no product-level SEO requirement? | No cross-cutting product ownership. Each module (blog, pages, contact, landing) was built in isolation by a developer who understands code, not necessarily SEO. No product owner reviewed the pages from a search engine's perspective. No acceptance criteria beyond "does the page render." |
| Why 5 | Why no cross-cutting product review? | Same root as Chain C: absent SDLC. No checklist, no review process, no "definition of done" that includes SEO, accessibility, or performance audits. The development workflow has no stage between "code complete" and "shipped." |
Root Cause: No cross-cutting product ownership and no definition of done that includes SEO. Metadata is treated as a code task rather than a product requirement, so it only appears where the code naturally supplies it.
The Irony: The root layout (layout.js) has a sophisticated generateMetadata function that fetches settings from the API, builds Open Graph tags, Twitter cards, and title templates. Someone clearly understood SEO for the root layout. But that knowledge did not propagate to the 4 child routes. The SEO implementation is "all or nothing" depending on whether that specific developer built that specific page.
Evidence -- The 4 Missing Routes:
| Route | Has generateMetadata? |
SEO Impact |
|---|---|---|
/ (homepage) |
NO | Most important page for organic search -- no title, description, or OG tags beyond root layout fallback |
/author/[slug] |
NO | Author pages invisible to search engines for author:name queries |
/category/[slug] |
NO | Category pages invisible to search engines for topic queries |
/contact |
NO | Contact page not indexed with business contact schema |
Fix: Add generateMetadata to all 4 routes (45 min). Structural fix: Pre-merge checklist or CI audit that verifies every route in (main-layout)/ exports metadata.
Chain E: Why Are All 7 CTA Buttons Non-Functional?¶
Symptom: The landing page has 7 call-to-action buttons across 3 sections (hero, pricing, CTA). Every single one renders visually -- styled, animated, responsive -- but does absolutely nothing when clicked. No href, no onClick, no router.push(). The primary conversion pathway for the entire marketing site is decorative.
| Depth | Question | Answer |
|---|---|---|
| Why 1 | Why do the buttons do nothing? | In hero-section.jsx:40-50, both buttons use <Button> from custom/button.jsx, which renders <button type="button"> -- a generic button with no action. No href prop (the custom Button component does not support href). No onClick handler passed. In cta-section.jsx:28-41, same pattern. In pricing-section.jsx:97-106, the shadcn ui/button.jsx is used, which supports asChild for link wrapping -- but asChild is not used. |
| Why 2 | Why was no action attached to these buttons? | The buttons were built as visual placeholders. The landing page is entirely static -- no API calls, no state, no routing logic. It was designed to LOOK like a SaaS marketing page, not to function as one. The buttons are styled identically to real CTAs but their implementation stops at the visual layer. |
| Why 3 | Why were the buttons left as placeholders? | The registration flow (/register) exists and works. The contact page (/contact) exists and works. The destinations are available. But nobody wired the buttons to the destinations. The landing page and the app's functional pages were built as separate workstreams that never connected. |
| Why 4 | Why were they built as separate workstreams? | Visual-demo mindset. The project's purpose is to be a "SaaS boilerplate" -- a starting point that SHOWS what a SaaS product looks like. The landing page fulfills its visual purpose. That it doesn't actually convert visitors is outside the mental model of "boilerplate." |
| Why 5 | Why does the visual-demo mindset persist into the functional layer? | The pricing data proves this definitively. The landing page constants (landing-page-data.js) define 3 plans: Starter at \(29**, Professional at **\)79, Enterprise at $199. But the dashboard pricing (lib/pricing-data.js) defines 4 plans: Basic at \(19**, Standard at **\)29, Premium at \(59**, Enterprise at **\)99. Different plan names, different plan counts, different prices, different feature lists. These are two unrelated pricing fantasies maintained by the same codebase. If the landing page pricing were real, clicking "Start Free Trial" on the $79 Professional plan would need to route to a checkout for a plan that does not exist in the dashboard. |
Root Cause: Visual-demo mindset -- modules built to LOOK complete rather than BE complete. The landing page and the application are parallel universes with contradictory data, connected only by sharing a node_modules directory.
The Pricing Contradiction (Exhibit A):
| Attribute | Landing Page (landing-page-data.js) |
Dashboard (pricing-data.js) |
|---|---|---|
| Plan count | 3 | 4 |
| Plan names | Starter, Professional, Enterprise | Basic, Standard, Premium, Enterprise |
| Lowest price | $29/mo | $19/mo |
| Mid price | $79/mo | $29/mo, $59/mo |
| Highest price | $199/mo | $99/mo |
| Annual pricing | Hardcoded inline ($23, $63, $159) | Separate array ($190, $290, $590, $990) |
| Data source | constants/landing-page-data.js |
lib/pricing-data.js |
These two files exist in the same repository, describe the same product, and agree on nothing.
The Button Inventory:
| Location | Button Text | Has href? |
Has onClick? |
Functional? |
|---|---|---|---|---|
hero-section.jsx:40 |
"Start Free Trial" | No | No | NO |
hero-section.jsx:44 |
"Book a Demo" | No | No | NO |
cta-section.jsx:28 |
"Start Free Trial" | No | No | NO |
cta-section.jsx:35 |
"Schedule a Demo" | No | No | NO |
pricing-section.jsx:104 (x3) |
"Start Free Trial" / "Contact Sales" | No | No | NO |
Fix: Wire buttons to /register and /contact (30 min). Unify pricing data source (2 hours). Structural fix: Integration test that verifies every <Button> on the landing page has either href or onClick.
Chain F: Why Are console.log Statements Left in Production Code?¶
Symptom: At least 3 console.log statements exist in production route handlers: category/[slug]/page.js:19 logs "category blogs" with the full data array, [slug]/page.js:42 logs the slug, blog-page-frontend.jsx:10 logs the full blog object. Additionally, the copy-pasted error logs in settings.js and all API modules log to console on every error.
| Depth | Question | Answer |
|---|---|---|
| Why 1 | Why are console.log statements in production code? | They are debugging remnants. console.log("category blogs", data?.blogs) in the category page is a classic "let me see what the API returned" debug statement that was never removed. The blog detail page similarly logs the entire blog object. |
| Why 2 | Why were they not removed before shipping? | No pre-commit hook checks for console.log. No linting rule flags it. The developer likely does not run npm run lint before committing (and even if they did, next/core-web-vitals does not include no-console). |
| Why 3 | Why is no-console not in the ESLint config? |
The ESLint config (eslint.config.mjs) contains exactly one line of configuration: compat.extends("next/core-web-vitals"). No custom rules whatsoever. The config was set up by create-next-app and never customized. |
| Why 4 | Why was ESLint never customized? | Same root as every other chain: no quality gate enforcement. ESLint exists in the project (it was installed by the Next.js scaffolding tool), but it serves as a decorative dependency -- installed but not configured, configured but not enforced, enforced but not in CI. |
| Why 5 | Why no enforcement? | No automated quality gates. No CI pipeline runs npm run lint. No pre-commit hook runs lint-staged. No branch protection requires passing checks. The npm run lint script exists but is purely voluntary. In a voluntary-discipline model, console.log statements are the cockroaches of development -- they breed in the dark and scatter when you look. |
Root Cause: No automated quality gates -- no no-console ESLint rule, no pre-commit hooks, no CI enforcement. Console.log statements are the visible symptom of a deeper absence of linting discipline.
The Evidence Trail:
- eslint.config.mjs: extends("next/core-web-vitals") only -- no no-console rule
- package.json: no Husky, no lint-staged, no pre-commit hook dependencies
- No .husky/ directory
- No .github/workflows/ directory
- 3+ explicit console.log statements in route handlers, plus copy-pasted error logs in all 5 API modules
Fix: Add no-console: ["error", { allow: ["warn", "error"] }] to ESLint config (5 min). Remove existing console.log statements (15 min). Structural fix: Husky + lint-staged pre-commit hook that runs ESLint on staged files.
Root Cause Map¶
All 6 chains converge on 3 systemic root causes. Here is the mapping:
┌─────────────────────────────┐
│ ROOT CAUSE #1 │
│ No Automated Quality Gates │
│ (No CI, No hooks, No lint) │
└──────────┬──────────────────-┘
│
┌──────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Chain A │ │ Chain C │ │ Chain F │
│ Broken │ │ Zero │ │ console │
│ Cache │ │ Tests │ │ .log in │
│ Keys │ │ │ │ prod │
└──────────┘ └──────────┘ └──────────┘
┌─────────────────────────────┐
│ ROOT CAUSE #2 │
│ Visual-Demo Mindset │
│ (Look like SaaS, not be it) │
└──────────┬──────────────────-┘
│
┌──────────┼──────────┐
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ Chain D │ │ Chain E │
│ Missing │ │ Dead │
│ SEO │ │ CTAs │
└──────────┘ └──────────┘
┌─────────────────────────────┐
│ ROOT CAUSE #3 │
│ No Security Discipline │
│ (No sanitize, no threat │
│ model, no security lint) │
└──────────┬──────────────────-┘
│
▼
┌──────────┐
│ Chain B │
│ Unsani- │
│ tized │
│ HTML │
└──────────┘
Cross-chain reinforcement:
- Root Cause #1 enables Root Cause #3: if there were a CI pipeline, react/no-danger would be enforceable
- Root Cause #1 enables Root Cause #2: if there were tests, dead CTAs and missing SEO would be caught
- Root Cause #2 enables Root Cause #1: "it's just a demo" mindset deprioritizes testing and CI investment
- Chain C (zero tests) is both a symptom of Root Cause #1 AND an amplifier of all other chains
3 Systemic Issues¶
Systemic Issue #1: Total Absence of Feedback Loops¶
Chains affected: A, B, C, F (4 of 6)
This codebase has no mechanism for telling the developer "something is wrong." No tests fail. No lint rules block. No CI rejects. No pre-commit hook catches. The developer's feedback loop is: write code -> look at browser -> looks okay -> push.
How it manifests:
- unstable_cache with wrong keys LOOKS correct -- the page renders, just with wrong data (Chain A)
- dangerouslySetInnerHTML LOOKS correct -- the HTML renders beautifully, the XSS is invisible until exploited (Chain B)
- Console.log LOOKS harmless -- the page works fine, the logs are invisible unless you check server output (Chain F)
- Zero tests means zero failures -- a false green signal (Chain C)
The insight: Every bug in this codebase is a bug that LOOKS correct to a human eye in a browser. The bugs are semantic, not visual. Only automated tooling can catch semantic bugs at scale. This project has zero automated tooling.
Systemic Issue #2: Modules as Islands¶
Chains affected: A, D, E (3 of 6)
Every module was built as a self-contained island. The landing page does not know about the registration flow. The pricing constants do not know about the subscription module. The blog routes do not know about SEO requirements. The 5 API files do not know they are copies of each other.
How it manifests:
- 5 API files copy-pasted instead of sharing an abstraction -- each island reinvents the unstable_cache wrapper (Chain A)
- Landing page pricing (\(29/\)79/\(199) contradicts dashboard pricing (\)19/\(29/\)59/$99) -- two islands with different realities (Chain E)
- SEO metadata appears only where a specific developer thought about it -- no island-crossing requirement (Chain D)
The insight: There is no "architect" role in this project -- no one whose job is to see how the islands connect. Each file was written by someone solving a local problem without awareness of the global system.
Systemic Issue #3: Security as Afterthought¶
Chains affected: B (and implicitly A via data integrity)
Security is not a design constraint in this codebase. It is neither considered during development (no threat modeling), nor enforced during review (no security lint rules), nor verified during testing (no security tests). The dangerouslySetInnerHTML API is a bright red warning sign from React itself -- and it is used 9 times without a single safety measure.
How it manifests:
- 9 dangerouslySetInnerHTML usages, 0 sanitization calls (Chain B)
- No DOMPurify, no sanitize-html in package.json (Chain B)
- No react/no-danger ESLint rule (Chain B)
- No CSRF protection on contact form (Expert Panel finding #56)
- No rate limiting on any public endpoint
The insight: Security is not "one more feature to add." Security is a property of the development PROCESS. You cannot bolt security onto a codebase that was built without it -- you have to retrofit it into the development workflow (lint rules, CI checks, dependency audits).
Fix-One-Fix-Many: The Leverage Points¶
The beauty of systemic root causes is that a single fix at the root prevents multiple symptoms. Here is the leverage analysis:
Highest Leverage: CI Pipeline with 4 Rules (Root Cause #1)¶
A single GitHub Actions workflow that enforces these 4 checks would have prevented 4 of 6 chains:
| CI Rule | Prevents Chain | How |
|---|---|---|
npm run lint (with no-console: error) |
Chain F | console.log statements blocked at merge |
npm run lint (with react/no-danger: warn) |
Chain B | dangerouslySetInnerHTML flagged for review |
npm run test (with minimum coverage) |
Chain A, C | Cache key tests would catch copy-paste bugs |
npm run build (must succeed) |
Chain E (partial) | Type errors and import issues caught |
Effort: 8-12 hours (ADR-5 from the ADR document) Impact: Prevents recurrence of 4/6 root patterns across ALL future modules, not just the public site
Second Leverage: Shared Utility Abstraction (Chain A specific)¶
// src/api/public/cached-fetch.js -- the function that should exist
import { unstable_cache } from "next/cache";
export function createCachedFetch(name, fetcher) {
return (params = {}) => {
const cacheKey = [name, JSON.stringify(params)];
return unstable_cache(
() => fetcher(params),
cacheKey,
{
revalidate: (parseInt(process.env.NEXT_PUBLIC_CACHE_TIME) || 1) * 60 * 60,
tags: [name],
}
)();
};
}
Effort: 30 minutes to create + 30 minutes to migrate 5 files Impact: Eliminates the copy-paste pattern entirely. The cache-key-includes-params invariant is defined once. Future API functions cannot get it wrong because they do not handle cache keys at all.
Third Leverage: <SafeHTML> Component (Chain B specific)¶
// src/components/common/safe-html.jsx
import DOMPurify from "dompurify";
export function SafeHTML({ html, className }) {
const clean = DOMPurify.sanitize(html || "");
return <div className={className} dangerouslySetInnerHTML={{ __html: clean }} />;
}
Effort: 15 minutes to create + 30 minutes to replace all 9 raw usages
Impact: Eliminates ALL stored XSS vectors. New developers use <SafeHTML> instead of dangerouslySetInnerHTML directly. The react/no-danger lint rule catches anyone who tries to bypass it.
Ordered List of Systemic Fixes¶
Prioritized by leverage (impact per hour of effort), not by severity:
| Priority | Fix | Effort | Chains Blocked | Issues Resolved |
|---|---|---|---|---|
| 1 | Fix unstable_cache keys in 5 files |
30 min | A | C1, C2, C3, C4, C5 (5 Critical) |
| 2 | Install DOMPurify + create <SafeHTML> + replace all 9 usages |
1 hr | B | C6, C7 (2 Critical) |
| 3 | Wire CTA buttons to /register and /contact |
30 min | E (partial) | H1 (1 High, 7 buttons) |
| 4 | Add generateMetadata to 4 missing routes |
45 min | D | H2, M2, M3, M4 (4 issues) |
| 5 | Remove console.log + add no-console ESLint rule |
20 min | F | M5, M6, M7 (3 Medium) |
| 6 | Extract cachedFetch utility to prevent cache key recurrence |
1 hr | A (structural) | Prevents future clones |
| 7 | Initialize Vitest + write smoke tests for 5 API functions | 4 hrs | C (structural) | Prevents ALL chains from recurring |
| 8 | Add Husky + lint-staged pre-commit hook | 1 hr | A, B, F (structural) | Blocks bad code before commit |
| 9 | Create CI pipeline (GitHub Actions) with lint + test + build | 4 hrs | A, B, C, F (structural) | Blocks bad code before merge |
| 10 | Unify pricing data source (landing page + dashboard) | 2 hrs | E (structural) | M9 + pricing contradiction |
| 11 | Add react/no-danger ESLint rule |
5 min | B (structural) | Flags future raw HTML usage |
| 12 | Add API contract test (verify frontend hooks match backend routes) | 2 hrs | C (structural) | Prevents phantom endpoints |
Phase 1 (Ship Blockers): Items 1-5 -- ~3 hours, resolves 15 issues including all 7 Criticals Phase 2 (Structural Prevention): Items 6-12 -- ~14 hours, prevents all 6 patterns from recurring
Total: ~17 hours of investment to resolve 15 immediate issues and structurally prevent 6 systemic failure patterns across all current and future modules.
The Reinforcing Cycle¶
┌──────────────────┐
│ No Quality Gates │◄───── "It's just a boilerplate"
│ (CI, hooks, lint)│ (visual-demo mindset)
└────────┬─────────┘ ▲
│ │
▼ │
┌──────────────────┐ ┌────────┴─────────┐
│ Can't Catch │──────►│ Copy-Paste & │
│ Bugs at │ │ Island-Building │
│ Commit/Merge │ │ Feel "Efficient"│
└──────────────────┘ └────────┬─────────┘
│
▼
┌──────────────────┐
│ Bugs Accumulate │
│ Invisibly │
│ (70 issues, 0 │
│ ever caught │
│ automatically) │
└──────────────────┘
Breaking the cycle: Attack Priority #1 (fix cache keys) for immediate relief. Then attack Priority #7-9 (testing + hooks + CI) to install the feedback loops that prevent the cycle from restarting. Without feedback loops, every fix in this document is a one-time patch. With feedback loops, every fix is a permanent guard rail.
Cross-References¶
- ADRs (architectural solutions):
docs/extra-docs/landing-page-adrs.md - Attack scenarios (exploitation of Chain B):
docs/extra-docs/landing-page-attack-scenarios.md - Pre-mortem (failure consequences):
docs/extra-docs/landing-page-pre-mortem.md - Email template root cause analysis (same patterns found):
docs/extra-docs/email-template-root-cause-analysis.md - RBAC deep-dive (#76, super_admin bypass -- same root as Chain B):
docs/deep-dive-user-management-rbac.md
Generated by BMAD Advanced Elicitation -- 5 Whys Deep Dive method | 2026-02-25