Skip to content

Architecture Decision Records -- Landing Page & Public Site

Author: Winston (Architect) Date: 2026-02-25 Source Analysis: docs/deep-dive-landing-page-public-site.md (45 issues: 7C, 7H, 17M, 14L) Phase 1 Inputs: Red Team attack scenarios, Pre-mortem incident forecasts, 5 Whys root cause analysis


Table of Contents

  1. ADR-1: Caching Architecture for Public Data Fetching
  2. ADR-2: HTML Sanitization Strategy for User-Generated Content
  3. ADR-3: CTA Button Wiring & Conversion Flow
  4. ADR-4: Public Site SEO Infrastructure
  5. ADR-5: Quality Gates & CI Pipeline

ADR-1: Caching Architecture for Public Data Fetching

Status: ✅ Implemented (2026-04-07) — All 5 public API functions migrated from unstable_cache to fetch() with next.revalidate. Error handling fixed. Navigation links client/server separated. Minor remaining: no shared cache-config constant, sitemap.js still uses axios. Date: 2026-02-25 Deciders: Project maintainers Related Issues: C1 ✅, C2 ✅, C3 ✅, C4 ✅, C5 ✅, H7 ✅, M8 ✅, M17 ✅

Context:

All 5 server-side data-fetching functions in the public API layer use Next.js unstable_cache with cache keys that omit their parameters. This means:

  • getBlogs({ page: 2, category: "tech" }) shares cache key ["blogs"] with getBlogs({ page: 1 }) -- the first call's result is returned for all subsequent calls regardless of parameters.
  • getBlogDetails("my-post") shares cache key ["blog-details"] with every other blog slug.
  • getDynamicPage("about") shares cache key ["dynamic-page"] with getDynamicPage("terms").
  • getSettingByGroup("contact") shares cache key ["settings-group"] with getSettingByGroup("site").
  • getNavigationLinks("navbar") shares cache key ["navigation-links"] with getNavigationLinks("footer").

This is the single most impactful bug in the public site. Every cached data-fetching call produces incorrect data after the first invocation per cache key. The Red Team analysis identified this as enabling a "Cache Poisoning" attack where the first request after cache expiry determines content for all visitors.

Additionally, unstable_cache is explicitly marked as experimental by the Next.js team. The API may change or be removed in future Next.js versions.

The affected files are: - Sass-boilerplate-frontend-v1/src/api/public/blogs.js (2 functions) - Sass-boilerplate-frontend-v1/src/api/public/dynamic-page.js (1 function) - Sass-boilerplate-frontend-v1/src/api/public/settings.js (1 function) - Sass-boilerplate-frontend-v1/src/api/public/navigation-links.js (1 function)

Decision Drivers: - All 5 cache functions are broken and producing incorrect data (Critical severity) - unstable_cache is an experimental API -- its stability is not guaranteed across Next.js upgrades - The project uses Next.js 15, which offers newer caching primitives (use cache directive) - This is a boilerplate project -- adopters will inherit whichever caching approach is chosen - generateMetadata and page components both call the same fetch functions, creating duplicate requests (M8) - The navigation-links.js module imports useQuery at the top level alongside server-side unstable_cache, bloating the server bundle (M17) - The revalidate value is configurable via NEXT_PUBLIC_CACHE_TIME env var (currently defaults to 1 hour) - Error handling is broken in all functions: non-OK responses fall through to .json() parsing (H7)

Options Considered

Option A: Fix Cache Keys In Place (Minimal Fix)

  • Description: Keep unstable_cache but fix the cache key arrays to include parameters. Fix error handling to return early on non-OK responses.
  • Implementation:
    // blogs.js -- getBlogs
    export const getBlogs = unstable_cache(
      async (params = {}) => { /* ... */ },
      // FIX: include params in cache key
      ["blogs", JSON.stringify(params)],  // <-- But this doesn't work!
      { revalidate: ..., tags: ["blogs"] }
    );
    
    Critical Problem: unstable_cache captures the key array at definition time, not at call time. The params variable is not available in the outer scope when the key array is evaluated. This means the cache key cannot dynamically incorporate function arguments in the current wrapper pattern. The function would need to be restructured:
    // Must return a NEW unstable_cache call per unique param set
    export const getBlogs = (params = {}) => {
      const cacheKey = ["blogs", JSON.stringify(params)];
      return unstable_cache(
        async () => { /* fetch with params in closure */ },
        cacheKey,
        { revalidate: ..., tags: ["blogs"] }
      )();
    };
    
  • Pros:
  • Smallest code change (restructure 5 functions)
  • No new dependencies
  • Preserves existing tag-based revalidation (revalidateTag("blogs"))
  • Works immediately
  • Cons:
  • Still depends on an experimental API (unstable_cache may change in Next.js 16+)
  • Creating a new unstable_cache wrapper per call undermines the caching benefit -- the function factory pattern is unusual and may confuse contributors
  • No protection against cache key collisions from user-controlled parameters (e.g., slug injection)
  • Boilerplate adopters inherit dependency on an unstable API
  • Effort: 2-3 hours
  • Risk: Medium. The restructured pattern works but is non-obvious. Future developers may revert to the broken pattern.
  • Description: Remove unstable_cache entirely. Use the native fetch() API with Next.js next.revalidate option, which automatically deduplicates and caches based on the full URL (including query parameters). This is the stable, documented, recommended caching approach in Next.js 15.
  • Implementation:
    // blogs.js
    export async function getBlogs(params = {}) {
      try {
        const { author, category, limit = 10, page = 1 } = params;
        const queryParams = new URLSearchParams();
        if (author) queryParams.append("author", author);
        if (category) queryParams.append("category", category);
        if (limit) queryParams.append("limit", limit);
        if (page) queryParams.append("page", page);
    
        const queryString = queryParams.toString();
        const url = `${process.env.API_URL}/public/blogs${queryString ? `?${queryString}` : ""}`;
    
        const response = await fetch(url, {
          method: "GET",
          headers: { "Content-Type": "application/json" },
          next: {
            revalidate: (parseInt(process.env.NEXT_PUBLIC_CACHE_TIME) || 1) * 60 * 60,
            tags: ["blogs"],
          },
        });
    
        if (!response.ok) {
          console.error("Failed to fetch blogs:", response.status);
          return null;
        }
    
        const data = await response.json();
        return data.data || null;
      } catch (error) {
        console.error("Error fetching blogs:", error);
        return null;
      }
    }
    
  • Pros:
  • Stable API: fetch with next.revalidate is the officially documented and stable caching mechanism in Next.js 15
  • Automatic deduplication: Next.js automatically deduplicates fetch calls with the same URL in a single render pass, solving M8 (duplicate calls in generateMetadata + page body)
  • Correct cache keys by design: Each unique URL is a unique cache entry -- no manual key management needed
  • Tag-based revalidation preserved: next.tags works with revalidateTag() for on-demand cache invalidation
  • Simpler code: Plain async function instead of unstable_cache wrapper -- more readable, more testable
  • Error handling is natural: Early return on !response.ok is straightforward
  • No new dependencies
  • Solves M17: The navigation-links.js module can be split -- server function in one file, useQuery hook in a separate client file
  • Cons:
  • Requires touching all 5 API files (but they are small: 30-67 lines each)
  • Tag-based revalidation syntax is slightly different (next.tags on the fetch options vs. tags on unstable_cache options) -- but functionally identical
  • fetch caching behavior can be surprising if developers forget to include next.revalidate (defaults to no caching in Next.js 15's dynamic = 'force-dynamic' mode)
  • Effort: 3-4 hours
  • Risk: Low. This is the canonical Next.js approach. Well-documented, stable, and understood by the Next.js community.

Option C: Use "use cache" Directive (Next.js 15 Canary)

  • Description: Adopt the new "use cache" directive introduced in Next.js 15 canary builds. Mark each fetching function with "use cache" at the top, configure cache profiles, and use cacheTag() / cacheLife() for control.
  • Implementation:
    // blogs.js
    "use cache";
    import { cacheTag, cacheLife } from "next/cache";
    
    export async function getBlogs(params = {}) {
      cacheTag("blogs");
      cacheLife("hours");
      // ... fetch logic
    }
    
  • Pros:
  • Most ergonomic API -- just add a directive at the top of the file
  • Automatic argument-based cache keying (function arguments become part of the cache key)
  • Built-in cache profiles ("hours", "days", etc.)
  • Future-proof -- this is the direction Next.js is heading
  • Cons:
  • Still experimental/canary: As of Next.js 15.3.x, "use cache" requires experimental.dynamicIO flag and is explicitly canary. Not recommended for production.
  • Requires Next.js config changes (next.config.mjs must enable experimental.dynamicIO)
  • Boilerplate adopters may not be on a canary-compatible version
  • Limited community knowledge and examples compared to fetch-based caching
  • May change significantly before stable release
  • Effort: 3-4 hours
  • Risk: High. Trading one experimental API (unstable_cache) for another ("use cache"). Inappropriate for a boilerplate.

Option D: External Cache Layer (Redis/Upstash)

  • Description: Introduce an external cache (e.g., Upstash Redis via @upstash/redis) for server-side caching, bypassing Next.js cache entirely.
  • Pros:
  • Full control over cache keys, TTL, invalidation
  • Works consistently across serverless and long-running deployments
  • Scales independently
  • Cons:
  • Massive overengineering for a boilerplate with 5 cached functions
  • Adds infrastructure dependency (Redis service required)
  • Adds package dependency (@upstash/redis or ioredis)
  • Increases deployment complexity for adopters
  • Doesn't leverage Next.js's built-in deduplication
  • Effort: 8-12 hours
  • Risk: High. Adds operational complexity disproportionate to the problem.

Decision

Chosen Option: B -- Migrate to fetch() with next.revalidate

This option is chosen because:

  1. Correctness by design. The fetch URL inherently includes all parameters, eliminating the class of "cache key missing parameters" bugs entirely. No manual key construction is needed.
  2. Stability. Unlike unstable_cache (Option A) and "use cache" (Option C), fetch-based caching is the stable, documented, and officially recommended approach for data fetching in Next.js 15.
  3. Deduplication. Next.js automatically deduplicates fetch calls with the same URL in a single server render, solving M8 without any additional code.
  4. Appropriate scope. Unlike Redis (Option D), this requires no new infrastructure and no new dependencies.
  5. Boilerplate-appropriate. Adopters will find well-documented, idiomatic Next.js code that follows the framework's conventions.

Consequences

  • Positive:
  • All 5 Critical caching bugs (C1-C5) are resolved
  • Duplicate API calls in metadata + page (M8) are resolved via automatic deduplication
  • Server bundle bloat from useQuery import (M17) can be resolved by file separation
  • Error handling (H7) is fixed as part of the migration
  • Code becomes simpler and more testable (plain async functions)
  • No experimental API dependency
  • Negative:
  • Developers must remember to include next: { revalidate: ... } in every fetch call -- omitting it means no caching
  • Minor migration effort touching 5 files
  • Risks:
  • If the backend API changes its URL structure, cache entries under old URLs become stale (mitigated by TTL expiry and revalidateTag)

Implementation Notes

  1. Migrate blogs.js: Convert both getBlogs and getBlogDetails from unstable_cache wrappers to plain async functions using fetch with next.revalidate and next.tags. Fix error handling to return early on !response.ok.
  2. Migrate dynamic-page.js: Same conversion for getDynamicPage.
  3. Migrate settings.js: Same conversion for getSettingByGroup.
  4. Split navigation-links.js: Move getNavigationLinks (server) to one file and useNavigationLinks (client hook using useQuery) to a separate file (e.g., use-navigation-links.js). This resolves M17.
  5. Create a shared constant for the revalidation duration to avoid repeating the (parseInt(...) || 1) * 60 * 60 expression in every file:
    // src/api/public/cache-config.js
    export const CACHE_REVALIDATE_SECONDS =
      (parseInt(process.env.NEXT_PUBLIC_CACHE_TIME) || 1) * 60 * 60;
    
  6. Test: Verify that different blog slugs, pagination pages, author filters, category filters, settings groups, and menu types all produce distinct cached responses.
  7. Estimated effort: 3-4 hours including testing.

ADR-2: HTML Sanitization Strategy for User-Generated Content

Status: ✅ Implemented (2026-04-07) — Dual-component approach: SanitizedHTMLPreview (isomorphic-dompurify, SSR-safe) for blog/pages + SandboxedHtmlPreview (iframe sandbox) for email templates. All 4 dangerouslySetInnerHTML locations replaced. Both dompurify and isomorphic-dompurify installed. Minor remaining: no ESLint rule for react/no-danger, minor config drift between the two components' allowed-tags. Date: 2026-02-25 Deciders: Project maintainers Related Issues: C6 ✅, C7 ✅, M13 ✅ (Pattern: Stored XSS via dangerouslySetInnerHTML)

Context:

The public site renders raw HTML from the database directly into the DOM using React's dangerouslySetInnerHTML in 4 locations:

Location File Field Source
Blog detail blog-page-frontend.jsx:94 blog.content Blog entity content column
Dynamic page dynamic-page.jsx:20 data?.description GenericPage entity description column
Email preview email-preview.jsx:288,292,297 Column content EmailTemplate visual builder
Email admin email-templates-page.jsx:805 template.html EmailTemplate entity

The blog module has backend sanitization via sanitize-html (through SanitizerFactory("dompurify") in blog.service.ts), which strips dangerous tags on write. However, the GenericPage module has zero sanitization -- content is stored and served exactly as submitted.

The Red Team analysis identified a critical attack chain: an attacker with blog-create or page-create permissions (obtainable via the known PATCH /users/profile self-role-change vulnerability) can inject <script> tags or event handlers that execute in every visitor's browser.

Additionally, even the blog sanitization has gaps: it runs sanitize-html on create and update, but content already in the database before sanitization was added remains unsanitized. There is no defense-in-depth on the rendering side.

Decision Drivers: - Two Critical XSS vulnerabilities (C6, C7) on public-facing pages - Backend sanitization exists for blogs but not for dynamic pages -- inconsistent defense - This is a boilerplate -- adopters need a clear, reusable pattern for safe HTML rendering - Content is rich HTML from a WYSIWYG editor; Markdown conversion would lose formatting - The backend already has a sanitize-html infrastructure (DomPurifySanitizer class) - Frontend is React 19 / Next.js 15 -- SSR context requires isomorphic sanitization or server-only - Performance matters: sanitization should not block rendering of large content

Options Considered

Option A: Client-Side DOMPurify Only

  • Description: Install dompurify on the frontend. Create a <SafeHTML> wrapper component that sanitizes HTML before rendering it via dangerouslySetInnerHTML.
  • Implementation:
    // src/components/custom/safe-html.jsx
    "use client";
    import DOMPurify from "dompurify";
    
    export default function SafeHTML({ html, className, as: Tag = "div" }) {
      const clean = DOMPurify.sanitize(html || "", {
        ALLOWED_TAGS: [
          "h1","h2","h3","h4","h5","h6","p","br","hr",
          "strong","em","u","s","b","i","a","img",
          "ul","ol","li","blockquote","pre","code",
          "table","thead","tbody","tr","th","td",
          "div","span","figure","figcaption","iframe"
        ],
        ALLOWED_ATTR: [
          "href","target","rel","src","alt","title",
          "width","height","class","id","style"
        ],
        ALLOW_DATA_ATTR: false,
      });
      return <Tag className={className} dangerouslySetInnerHTML={{ __html: clean }} />;
    }
    
    Usage: Replace dangerouslySetInnerHTML={{ __html: blog.content }} with <SafeHTML html={blog.content} className="blog-content" />.
  • Pros:
  • Defense-in-depth: even if backend sanitization is bypassed, client blocks XSS
  • DOMPurify is the gold standard for client-side HTML sanitization (~4.5 kB gzipped)
  • Reusable <SafeHTML> component establishes a pattern for the entire codebase
  • Simple to apply -- search-and-replace dangerouslySetInnerHTML with <SafeHTML>
  • Cons:
  • DOMPurify requires a DOM (uses document.createElement), so it cannot run in React Server Components -- the <SafeHTML> component must be "use client"
  • Adds ~4.5 kB to client bundle
  • Sanitization runs on every render (can memoize with useMemo, but content is typically static)
  • Does not fix the root cause (unsanitized content in the database)
  • Effort: 2-3 hours
  • Risk: Low for the component itself. Medium for false sense of security if developers assume client-side sanitization alone is sufficient.
  • Description: Extend the existing backend DomPurifySanitizer to cover all HTML write paths: Blog content (already done), GenericPage description (missing), and EmailTemplate HTML (missing). Apply sanitization in the service layer before persisting to the database.
  • Implementation:
    // generic-page.service.ts -- createPage method
    import { SanitizerFactory } from "../../../factories/sanitizer/sanitizer.factory";
    
    async createPage(payload: any) {
      const sanitizer = SanitizerFactory("dompurify");
      if (payload.description) {
        payload.description = sanitizer.sanitize(payload.description);
      }
      // ... existing create logic
    }
    
  • Pros:
  • Fixes the root cause: malicious HTML never reaches the database
  • Consistent with existing blog sanitization pattern (SanitizerFactory)
  • No frontend changes needed for new content types
  • Protects all consumers of the data (frontend, API, email, RSS, etc.)
  • Cons:
  • Does not protect against content already in the database
  • Does not protect if a new write path is added without sanitization
  • Backend-only: if an attacker gains direct DB access, no defense
  • Requires audit of all existing GenericPage content in production databases
  • Effort: 2-3 hours
  • Risk: Low. Extends an existing, working pattern.

Option C: Dual-Layer Sanitization (Backend Write + Frontend Read)

  • Description: Combine Options A and B. Sanitize on write (backend) and on read (frontend <SafeHTML> component). This is defense-in-depth.
  • Implementation: Both the backend changes from Option B and the frontend <SafeHTML> component from Option A.
  • Pros:
  • Defense-in-depth: two independent layers of protection
  • Handles both existing unsanitized data (frontend catches it) and new data (backend prevents storage)
  • Establishes a clear pattern for the boilerplate: "always sanitize on write AND on read"
  • <SafeHTML> component serves as a lint-friendly replacement for dangerouslySetInnerHTML -- can add an ESLint rule to flag direct dangerouslySetInnerHTML usage
  • Cons:
  • Double sanitization means content is processed twice (negligible performance cost for typical content sizes)
  • If sanitization configs differ between backend and frontend, content may render differently in admin preview vs. public view
  • More code to maintain (two sanitization configurations)
  • Effort: 4-5 hours
  • Risk: Low. Slightly more maintenance overhead, but significantly stronger security posture.

Option D: Replace HTML with Markdown Rendering

  • Description: Instead of storing and rendering raw HTML, convert the content pipeline to Markdown. Use a Markdown renderer (e.g., react-markdown with rehype-sanitize) on the frontend.
  • Pros:
  • Markdown is inherently safe -- no script injection possible
  • Smaller content size in the database
  • react-markdown + rehype-sanitize is well-maintained
  • Cons:
  • Breaking change: All existing content in the database is HTML from the WYSIWYG editor (BlockNote)
  • BlockNote outputs HTML or JSON block structures, not Markdown
  • Would require migrating all existing blog and page content
  • Loss of rich formatting capabilities (tables, embedded media, custom styles)
  • Admin dashboard would need significant rework
  • Effort: 20-30 hours (migration + editor changes + content conversion)
  • Risk: Very High. Disproportionate effort, breaks existing content, and limits the WYSIWYG editor's capabilities.

Decision

Chosen Option: C -- Dual-Layer Sanitization (Backend Write + Frontend Read)

This option is chosen because:

  1. Defense-in-depth is the security standard. For a boilerplate that will be forked and deployed by many teams, a single layer of sanitization is insufficient. If a developer accidentally bypasses backend sanitization (new endpoint, direct DB write, API import), the frontend <SafeHTML> component still blocks XSS.
  2. Fixes both root cause and symptoms. Backend sanitization prevents malicious HTML from entering the database (root cause). Frontend <SafeHTML> protects against existing unsanitized content and future regressions (symptom defense).
  3. Establishes a reusable pattern. The <SafeHTML> component becomes the standard way to render any user-generated HTML in the codebase. This can be enforced via ESLint rules (flag direct dangerouslySetInnerHTML usage).
  4. Practical effort. 4-5 hours is reasonable for eliminating 2 Critical-severity XSS vulnerabilities.
  5. Markdown migration (Option D) is disproportionate. The content pipeline is HTML-based by design (BlockNote editor). Converting to Markdown would break the admin experience and require content migration.

Consequences

  • Positive:
  • C6 and C7 (Stored XSS) are fully resolved
  • All 4 dangerouslySetInnerHTML locations in public pages are protected
  • New content types get backend sanitization at the service layer
  • <SafeHTML> component available for all future HTML rendering needs
  • Can add ESLint rule: no-restricted-syntax for dangerouslySetInnerHTML to prevent future occurrences
  • Negative:
  • Frontend bundle grows by ~4.5 kB (DOMPurify)
  • Two sanitization configurations to maintain and keep in sync
  • <SafeHTML> is a client component -- content rendered by it is not part of the initial SSR HTML (SEO impact for blog content)
  • Risks:
  • Configuration drift: if backend allows <iframe> but frontend strips it (or vice versa), content renders differently in admin vs. public view. Mitigation: Centralize the allowed-tags list in a shared config or document the canonical list.
  • SSR gap: Since <SafeHTML> uses DOMPurify (requires DOM), it must be a client component. Blog content will not be in the initial HTML sent to crawlers. Mitigation: For SSR, consider isomorphic-dompurify which works in Node.js via JSDOM, or use sanitize-html (already a backend dependency) on the server and DOMPurify on the client.

Implementation Notes

  1. Frontend -- Create <SafeHTML> component:
  2. File: Sass-boilerplate-frontend-v1/src/components/custom/safe-html.jsx
  3. Install: npm install dompurify (or isomorphic-dompurify for SSR compatibility)
  4. Match the allowed-tags list to the backend DomPurifySanitizer config in saas-boilerplate/src/app/infrastructure/sanitizer/dompurify.sanitizer.ts
  5. Frontend -- Replace dangerouslySetInnerHTML usage:
  6. blog-page-frontend.jsx:94 -- replace with <SafeHTML html={blog.content} className="blog-content" />
  7. dynamic-page.jsx:20 -- replace with <SafeHTML html={data?.description} className="blog-content" />
  8. email-templates-page.jsx:805 -- replace (admin-only, lower priority)
  9. email-preview.jsx:288,292,297 -- replace (admin-only, lower priority)
  10. Backend -- Add sanitization to GenericPage service:
  11. File: saas-boilerplate/src/app/modules/v1/GenericPage/generic-page.service.ts
  12. Add SanitizerFactory("dompurify") calls in createPage and updatePage methods for the description field
  13. Backend -- Add sanitization to EmailTemplate service (lower priority, admin-only content):
  14. File: saas-boilerplate/src/app/modules/v1/EmailTemplate/email-template.service.ts
  15. Shared configuration: Document the canonical allowed-tags list in a code comment or shared constant referenced by both frontend and backend configs.
  16. Estimated effort: 4-5 hours including testing across blog, dynamic page, and email template views.

ADR-3: CTA Button Wiring & Conversion Flow

Status: ✅ Partially Implemented (2026-04-07) — V2 landing page components (hero-section-v2, cta-section-v2) correctly wire CTAs to /register. V1 components are inactive and retain dead buttons. custom/button.jsx M14/M15 bugs remain but are not exposed in the active v2 landing page. Pricing uses dynamic API data. ctaActions config in landing-page-data.js not implemented (v2 hardcodes links directly). Date: 2026-02-25 Deciders: Project maintainers Related Issues: H1 ✅ (v2), M9 ⚠️ (pricing from API, not static data), M13 ✅ (v2 uses ui/button), M14 ⚠️ (bug exists in custom/button but unused in v2), M15 ⚠️ (same), L14 ✅ (v2 uses consistent labeling)

Context:

The landing page has 7 Call-to-Action buttons across 3 sections, and none of them do anything:

Section Button Text Component Current Behavior
Hero "Start Free Trial" custom/button.jsx No href, no onClick -- dead
Hero "Book a Demo" custom/button.jsx No href, no onClick -- dead
Pricing (x3) "Start Free Trial" / "Contact Sales" ui/button.jsx No href, no onClick -- dead
CTA "Start Free Trial" custom/button.jsx No href, no onClick -- dead
CTA "Schedule a Demo" custom/button.jsx No href, no onClick -- dead

Additionally: - Landing page pricing (\(29/\)79/\(199) does not match the dashboard subscription pricing (\)19/\(29/\)59/$99), creating a trust violation if both are visible. - Annual pricing data is hardcoded inline in pricing-section.jsx (not from the shared pricingPlans constant), creating maintenance drift (M9). - Two different button components (custom/button.jsx and ui/button.jsx) are used on the same page with different APIs and visual behavior (M13). - The custom/button.jsx has a duplicate secondary variant key (M14) and non-functional focus ring styles (M15). - "Book a Demo" (hero) vs. "Schedule a Demo" (CTA) is inconsistent labeling for the same intent (L14).

This is a boilerplate -- the landing page serves as a template. The solution must be flexible enough that adopters can easily customize the CTA destinations, yet functional out-of-the-box so the boilerplate demonstrates a working conversion flow.

Decision Drivers: - 7 dead buttons is the most visible UX failure on the public site - This is a boilerplate: CTAs must work out-of-the-box AND be easy to customize - The existing auth flow supports /register and /login pages - Pricing data should have a single source of truth (no inline duplication) - Button component inconsistency (2 components, 1 with bugs) should be resolved - No backend changes should be required for CTA wiring

Options Considered

Option A: Wire All CTAs to /register with Pricing Unification

  • Description: Link all "Start Free Trial" buttons to /register. Link "Book/Schedule a Demo" to /contact. Unify pricing data to use a single source. Standardize on one button component.
  • Implementation:
    // hero-section.jsx
    import Link from "next/link";
    // ...
    <Link href="/register">
      <Button size="lg" className="rounded-full h-12 px-8 text-base">
        Start Free Trial
        <ArrowRight className="ml-2 size-4" />
      </Button>
    </Link>
    <Link href="/contact">
      <Button size="lg" variant="outline" className="rounded-full h-12 px-8 text-base">
        Book a Demo
      </Button>
    </Link>
    
    For pricing buttons, use ui/button.jsx with asChild:
    <Button asChild className="w-full mt-auto rounded-full" variant={plan.popular ? "default" : "outline"}>
      <Link href={plan.ctaLink}>{plan.cta}</Link>
    </Button>
    
    Update landing-page-data.js:
    export const pricingPlans = [
      {
        name: "Starter",
        monthlyPrice: 29,
        annualPrice: 23,
        // ...
        cta: "Start Free Trial",
        ctaLink: "/register?plan=starter",
      },
      // ...
    ];
    
  • Pros:
  • Simple, immediate fix -- all buttons become functional
  • /register is the natural conversion endpoint for a SaaS boilerplate
  • Passing ?plan=starter in the URL allows the registration page to pre-select a plan
  • "Book a Demo" pointing to /contact leverages the existing contact form
  • Single source of truth for pricing data (monthly and annual in one object)
  • Easy to customize: adopters just change ctaLink values
  • Cons:
  • Registration page doesn't currently handle ?plan= parameter (would need frontend work)
  • "Book a Demo" going to a generic contact form is a weak demo experience
  • Pricing amounts (\(29/\)79/\(199) still mismatch dashboard pricing (\)19/\(29/\)59/$99) -- choosing which to keep is a product decision
  • Effort: 3-4 hours
  • Risk: Low for wiring. Medium for pricing unification (requires product decision on correct prices).

Option B: Config-Driven CTA Destinations

  • Description: Make CTA destinations configurable via a cta-config.js constants file. This allows adopters to change all CTA behavior from a single file without modifying components.
  • Implementation:
    // src/constants/cta-config.js
    export const CTA_CONFIG = {
      primaryAction: {
        label: "Start Free Trial",
        href: "/register",
      },
      secondaryAction: {
        label: "Book a Demo",
        href: "/contact",
      },
      pricing: {
        starter: { href: "/register?plan=starter" },
        professional: { href: "/register?plan=professional" },
        enterprise: { href: "/contact?subject=Enterprise%20Inquiry" },
      },
    };
    
    Components read from this config:
    import { CTA_CONFIG } from "@/constants/cta-config";
    // ...
    <Link href={CTA_CONFIG.primaryAction.href}>
      <Button>{CTA_CONFIG.primaryAction.label}</Button>
    </Link>
    
  • Pros:
  • Maximum customizability for boilerplate adopters
  • Single file change to rewire all CTAs (add Calendly link, Stripe Checkout URL, etc.)
  • Separates content/config from component logic
  • Consistent labeling enforced (one "Book a Demo" text, not two different strings)
  • Cons:
  • Slight indirection (developers must look up config file instead of reading component directly)
  • Config file is another thing to maintain
  • Over-engineering for a boilerplate if most adopters will just edit the components directly
  • Effort: 4-5 hours
  • Risk: Low.
  • Description: Wire pricing CTAs directly to Stripe Checkout sessions using Stripe Payment Links. Each plan button opens a Stripe-hosted checkout page.
  • Pros:
  • Shortest path from "visitor" to "paying customer"
  • No registration flow needed first
  • Stripe handles pricing display consistency
  • Cons:
  • Requires Stripe Payment Links configured in production (adds deployment prerequisite)
  • Bypasses the registration flow entirely -- user has no account after payment
  • Stripe Payment Links are environment-specific (test vs. live) -- cannot be statically configured in a boilerplate
  • "Start Free Trial" doesn't map to Stripe Checkout (trials need Stripe Subscriptions API)
  • Most boilerplate adopters will customize pricing; hardcoded Stripe links are useless to them
  • Effort: 6-8 hours (Stripe setup + webhook handling + account creation post-payment)
  • Risk: High. Ties the boilerplate to a specific Stripe configuration that doesn't exist out-of-the-box.

Decision

Chosen Option: A -- Wire All CTAs to /register with Pricing Unification, incorporating the config approach from Option B for CTA labels/links

Specifically: a hybrid of A and B. Wire CTAs to /register and /contact as immediate destinations, but move all CTA text and link values into the existing landing-page-data.js constants file (extending Option B's spirit without a separate config file). Unify monthly and annual pricing into a single data structure.

This is chosen because:

  1. Functional out-of-the-box. /register and /contact already exist and work. No new pages or backend changes needed.
  2. Easy to customize. All CTA labels and links are in landing-page-data.js alongside pricing, features, testimonials, and FAQs. One file to edit.
  3. Pricing unification. A single pricingPlans array with both monthlyPrice and annualPrice eliminates the inline annual pricing duplication (M9).
  4. Component cleanup. Standardize on ui/button.jsx (Radix-based, supports asChild for links) and phase out custom/button.jsx from landing page sections. This resolves M13, M14, and M15.
  5. Stripe integration (Option C) is premature. The boilerplate should demonstrate the conversion funnel pattern; adopters add their own payment integration.

Consequences

  • Positive:
  • All 7 CTA buttons become functional (H1 resolved)
  • Pricing data unified into single source of truth (M9 resolved)
  • Button component inconsistency resolved (M13)
  • custom/button.jsx bugs (M14, M15) bypassed by using ui/button.jsx
  • Consistent CTA labeling (L14 resolved)
  • Clear visitor-to-user conversion path: Landing Page -> Click CTA -> /register -> Dashboard
  • Negative:
  • Landing page pricing (\(29/\)79/\(199) vs. dashboard pricing (\)19/\(29/\)59/$99) mismatch remains a product decision outside this ADR's scope. Recommend flagging both sets of values and letting the project owner reconcile.
  • /register page does not currently handle ?plan= query parameter for plan pre-selection (nice-to-have, not a blocker)
  • Risks:
  • If /register is broken or API is down, all CTAs lead to an error page. Mitigated by error boundaries (addressed separately).

Implementation Notes

  1. Update landing-page-data.js:
  2. Add ctaLink field to each pricing plan
  3. Add monthlyPrice (number) and annualPrice (number) fields; compute display strings from these
  4. Add top-level CTA config:
    export const ctaActions = {
      primary: { label: "Start Free Trial", href: "/register" },
      secondary: { label: "Book a Demo", href: "/contact" },
    };
    
  5. Update hero-section.jsx: Wrap buttons in <Link> using ctaActions. Replace custom/button.jsx with ui/button.jsx.
  6. Update cta-section.jsx: Same treatment. Use ctaActions.secondary.label to unify "Book a Demo" / "Schedule a Demo" (L14).
  7. Update pricing-section.jsx: Remove inline annual pricing array. Compute annual tab from pricingPlans[].annualPrice. Wrap pricing buttons in <Link href={plan.ctaLink}>.
  8. Verify /register page loads correctly when accessed from landing page CTAs.
  9. Product decision needed: Reconcile landing page prices (\(29/\)79/\(199) with dashboard subscription prices (\)19/\(29/\)59/$99). This is a content/product decision, not an architecture decision.
  10. Estimated effort: 3-4 hours for wiring + cleanup. Pricing reconciliation is a separate task.

ADR-4: Public Site SEO Infrastructure

Status: ✅ Implemented (2026-04-07) — All 4 missing metadata pages added (generateMetadata on homepage, author, category, contact). notFound() used for proper 404 status. Title template error handling fixed. Dynamic sitemap.js and robots.js created. Metadata field naming consistent. generateStaticParams set up for author/category pages. Date: 2026-02-25 Deciders: Project maintainers Related Issues: H2 ✅, H6 ✅, M1 ✅, M2 ✅, M3 ✅, M4 ✅, M8 ✅, M11 ✅, M12 ✅, L1 ✅, L2 ✅, L3 ✅, L4 ✅

Context:

The public site has significant SEO deficiencies:

  1. Missing metadata (4 of 7 routes):
  2. Homepage (/) -- no generateMetadata (H2). The most important page for SEO relies entirely on root layout defaults.
  3. Author pages (/author/[slug]) -- no generateMetadata (M2)
  4. Category pages (/category/[slug]) -- no generateMetadata (M3)
  5. Contact page (/contact) -- no generateMetadata (M4)

  6. Broken 404 responses (M1): When a blog or dynamic page is not found, the code renders a <NotFound /> React component but returns HTTP 200. Search engines index these as valid pages with "404 - Page Not Found" content.

  7. Broken title template (H6): When the API is down, fetchMetaData in layout.js returns the string "SASS Boilerplate" instead of an object. generateMetadata accesses settings?.admin?.site_name on this string, producing undefined. All child page titles render as "Blogs | undefined".

  8. No robots.txt or sitemap.xml: Search engines have no crawl directives and no sitemap for efficient indexing.

  9. No canonical URLs: Pages accessible via multiple paths (e.g., /blogs?page=1 and /blogs) are not de-duplicated for search engines.

  10. Inconsistent metadata field names (M12): generateMetadata in blogs/[slug]/page.js uses blogData?.image but the component uses blog?.imageUrl.

Decision Drivers: - SEO is critical for a public-facing SaaS boilerplate -- the landing page and blog are the primary organic discovery channels - 4 of 7 routes are missing metadata entirely - The root layout already has a dynamic metadata pattern fetching from the settings API - Next.js 15 has built-in support for robots.ts, sitemap.ts, and generateMetadata - The 404 bug (M1) is particularly harmful: search engines index error pages as valid content - This is a boilerplate: the SEO setup should be easy to customize and extend

Options Considered

Option A: Static Metadata Per Page (Minimal Fix)

  • Description: Add export const metadata = { ... } or export function generateMetadata() to each of the 4 pages missing it. Add static robots.txt and sitemap.xml files. Fix the 404 HTTP status by using notFound() from next/navigation.
  • Implementation:
    // (main-layout)/page.js (Homepage)
    export const metadata = {
      title: "Home",
      description: "The all-in-one platform that helps teams collaborate, automate, and deliver exceptional results.",
      openGraph: {
        title: "Home",
        description: "The all-in-one platform that helps teams collaborate, automate, and deliver exceptional results.",
        type: "website",
      },
    };
    
    For 404 fix:
    // blogs/[slug]/page.js
    import { notFound } from "next/navigation";
    
    const page = async ({ params }) => {
      const paramsData = await params;
      const blogData = await getBlogDetails(paramsData?.slug);
      if (!blogData) {
        notFound(); // Returns HTTP 404, not 200
      }
      // ...
    };
    
    For robots.txt:
    // src/app/robots.js
    export default function robots() {
      return {
        rules: { userAgent: "*", allow: "/", disallow: "/dashboard/" },
        sitemap: `${process.env.NEXT_PUBLIC_SITE_URL}/sitemap.xml`,
      };
    }
    
  • Pros:
  • Straightforward, minimal changes
  • Each page controls its own metadata
  • notFound() is the Next.js standard for 404s
  • No new dependencies
  • Cons:
  • Static metadata doesn't incorporate site name or branding from the settings API
  • Hardcoded strings require manual updates if branding changes
  • sitemap.xml with hardcoded URLs won't include dynamic blog posts or pages
  • Effort: 3-4 hours
  • Risk: Low.
  • Description: Add generateMetadata to all 4 missing pages. Where applicable (author, category), fetch relevant data for rich metadata. Create a dynamic sitemap.ts that queries the backend for all published blog slugs and dynamic page slugs. Create robots.ts. Fix 404 status with notFound(). Fix the fetchMetaData error handler.
  • Implementation:
    // author/[slug]/page.js
    export async function generateMetadata({ params }) {
      const { slug } = await params;
      const author = decodeURIComponent(slug)?.split("-").join(" ");
      return {
        title: author,
        description: `Articles by ${author}`,
        robots: { index: true, follow: true },
        openGraph: {
          title: `Articles by ${author}`,
          description: `Browse all articles written by ${author}.`,
          type: "website",
        },
      };
    }
    
    For dynamic sitemap:
    // src/app/sitemap.js
    export default async function sitemap() {
      const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://example.com";
    
      // Static routes
      const staticRoutes = [
        { url: baseUrl, lastModified: new Date(), changeFrequency: "weekly", priority: 1 },
        { url: `${baseUrl}/blogs`, lastModified: new Date(), changeFrequency: "daily", priority: 0.8 },
        { url: `${baseUrl}/contact`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.5 },
      ];
    
      // Dynamic blog routes
      let blogRoutes = [];
      try {
        const response = await fetch(`${process.env.API_URL}/public/blogs?limit=1000`, {
          next: { revalidate: 3600 },
        });
        if (response.ok) {
          const data = await response.json();
          blogRoutes = (data?.data?.blogs || []).map((blog) => ({
            url: `${baseUrl}/blogs/${blog.slug}`,
            lastModified: new Date(blog.updatedAt),
            changeFrequency: "weekly",
            priority: 0.6,
          }));
        }
      } catch (e) {
        console.error("Sitemap: failed to fetch blogs", e);
      }
    
      return [...staticRoutes, ...blogRoutes];
    }
    
    Fix fetchMetaData error handler:
    // layout.js
    async function fetchMetaData() {
      try {
        // ... existing fetch logic
      } catch (error) {
        console.error("Failed to fetch dynamic title:", error);
        return { admin: { meta_title: "SaaS Boilerplate", site_name: "SaaS Boilerplate" } };
      }
    }
    
  • Pros:
  • Rich, dynamic metadata on every public page
  • Dynamic sitemap includes all published blog posts -- critical for blog SEO
  • Fixes the fetchMetaData error handler that causes "undefined" in title template (H6)
  • notFound() returns proper HTTP 404 status (M1)
  • Follows Next.js 15 conventions (sitemap.js, robots.js as route handlers)
  • Sitemap auto-updates as new blog posts are published
  • Cons:
  • Sitemap fetches all blog slugs from the API -- potential performance concern if there are thousands of posts (mitigated by next.revalidate)
  • More code to maintain than static metadata
  • Author/category pages generate metadata from URL slugs rather than dedicated API endpoints -- metadata quality depends on slug format
  • Effort: 5-6 hours
  • Risk: Low. All patterns are standard Next.js 15 features.

Option C: SEO Middleware + Centralized Metadata Service

  • Description: Create a centralized MetadataService that resolves metadata for any route. Use middleware to inject canonical URLs and SEO headers.
  • Pros:
  • Centralized metadata logic -- single point of change
  • Middleware can add canonical URLs, X-Robots-Tag headers, etc.
  • Could integrate with an SEO management dashboard
  • Cons:
  • Over-engineered for the current scope (7 routes)
  • Next.js middleware runs on the edge and has limited access to React rendering
  • Middleware cannot set <meta> tags -- only HTTP headers (which are less useful for social sharing)
  • Significant abstraction that may confuse boilerplate adopters
  • Effort: 10-15 hours
  • Risk: Medium. Adds unnecessary complexity for the benefit.

Decision

Chosen Option: B -- Dynamic Metadata + Dynamic Sitemap

This option is chosen because:

  1. Correct scope. It addresses all 4 missing metadata pages, the 404 status bug, the error handler bug, and adds robots.txt + sitemap.xml -- all of which are standard expectations for a public-facing site.
  2. Dynamic sitemap is essential. A blog-driven SaaS boilerplate needs its blog posts indexed. A static sitemap cannot include dynamically created content.
  3. Standard Next.js patterns. generateMetadata, sitemap.js, robots.js, and notFound() are all built-in Next.js 15 features. No custom abstractions needed.
  4. Fixes the "undefined" title bug (H6). The fetchMetaData error handler fix ensures child page titles never show "| undefined".
  5. Middleware approach (Option C) is over-engineered for 7 routes and adds abstractions that obscure standard Next.js metadata handling.

Consequences

  • Positive:
  • All 7 public routes have proper metadata (H2, M2, M3, M4 resolved)
  • 404 pages return HTTP 404 instead of 200 (M1 resolved)
  • "| undefined" title template bug fixed (H6 resolved)
  • robots.txt directs crawlers away from /dashboard/ and points to sitemap
  • sitemap.xml dynamically includes all published blog posts
  • Canonical URLs prevent duplicate content issues
  • Negative:
  • Sitemap generation adds one API call during sitemap regeneration (cached for 1 hour)
  • Author/category metadata is derived from URL slugs, not from a dedicated "author" or "category" entity -- metadata quality is limited
  • Risks:
  • If the blog listing API is down during sitemap generation, dynamic blog URLs are omitted from sitemap (fails gracefully to static routes only)
  • NEXT_PUBLIC_SITE_URL environment variable must be set correctly for sitemap and canonical URLs

Implementation Notes

  1. Fix fetchMetaData error handler in Sass-boilerplate-frontend-v1/src/app/layout.js: Return { admin: { meta_title: "SaaS Boilerplate", site_name: "SaaS Boilerplate", meta_description: "..." } } instead of a bare string. This fixes the title template "undefined" bug.
  2. Add generateMetadata to 4 pages:
  3. (main-layout)/page.js -- static metadata for homepage
  4. author/[slug]/page.js -- derive from slug
  5. category/[slug]/page.js -- derive from slug
  6. contact/page.js -- static metadata
  7. Fix 404 HTTP status in blogs/[slug]/page.js and [slug]/page.js: Replace return <NotFound /> with notFound() from next/navigation. This triggers the proper Next.js 404 mechanism.
  8. Create src/app/robots.js: Export a function returning crawl rules. Disallow /dashboard/.
  9. Create src/app/sitemap.js: Export an async function that fetches blog slugs from the public API and generates URL entries.
  10. Add NEXT_PUBLIC_SITE_URL to .env.example: Document it as required for SEO features.
  11. Estimated effort: 5-6 hours including testing all metadata with social sharing debugger tools.

ADR-5: Quality Gates & CI Pipeline

Status: Proposed Date: 2026-02-25 Deciders: Project maintainers Related Issues: Root cause of systemic patterns (5 Whys analysis), M5, M6, M7 (console.log), M14 (duplicate key), M15 (dead CSS), L1 (naming convention), and cross-cutting concerns across all 45 issues

Context:

The 5 Whys root cause analysis identified the absence of quality gates as the root cause of 4 out of 6 systemic patterns in the public site:

  1. Pattern A (5 identical cache bugs): Copy-paste development with no tests to catch the error propagating.
  2. Pattern B (4 unsanitized HTML locations): No security linting or XSS detection rules.
  3. Pattern C (3 console.log in production): No no-console ESLint rule.
  4. Pattern D (4 missing SEO metadata routes): No audit or checklist enforced at PR time.

The current quality gate status: - Tests: Zero. No test script in either package.json. No test runner installed. No test files exist. - CI/CD: None. No GitHub Actions workflows, no CircleCI, no Jenkins. - Pre-commit hooks: None. No Husky, no lint-staged. - ESLint: Bare minimum -- only next/core-web-vitals extends. No no-console, no security rules, no import ordering. - Git workflow: Direct pushes to main. No branch protection, no PR process.

This is a boilerplate project. Adopters will fork it and inherit whatever quality infrastructure exists. Setting up quality gates in the boilerplate means every fork starts with a safety net.

Decision Drivers: - Zero tests, zero CI, zero hooks -- the root cause of most bugs shipping undetected - This is a boilerplate: quality infrastructure is a feature, not overhead - The project has two codebases (frontend: Next.js/JS, backend: Express/TS) with different toolchains - Adopters should inherit working CI that they can extend, not configure from scratch - Must not be so heavy that it slows down development or discourages contributions - The frontend uses Next.js 15 + React 19 (must choose compatible test tools) - The backend uses TypeScript + Express + TypeORM

Options Considered

Option A: Minimal -- Husky + lint-staged + ESLint Rules Only

  • Description: Add pre-commit hooks that run ESLint on staged files. Enhance ESLint configuration with rules that would have caught existing bugs. No test runner, no CI.
  • Implementation: Frontend ESLint enhancement (eslint.config.mjs):
    const eslintConfig = [
      ...compat.extends("next/core-web-vitals"),
      {
        rules: {
          "no-console": ["warn", { allow: ["error"] }],
          "no-duplicate-case": "error",
          "no-dupe-keys": "error",          // Catches M14 (duplicate variant key)
          "react/no-danger": "warn",         // Flags dangerouslySetInnerHTML
          "no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
        },
      },
    ];
    
    Husky + lint-staged setup:
    // package.json additions
    {
      "scripts": {
        "prepare": "husky"
      },
      "lint-staged": {
        "*.{js,jsx}": ["eslint --fix", "prettier --write"],
        "*.{json,md}": ["prettier --write"]
      }
    }
    
  • Pros:
  • Prevents future regressions: no-console catches M5/M6/M7, no-dupe-keys catches M14, react/no-danger flags XSS risks
  • Minimal setup (30 minutes)
  • No CI infrastructure needed
  • Runs locally, fast feedback
  • Low friction for developers
  • Cons:
  • No tests -- bugs like C1-C5 (cache key logic errors) are not detectable by linting
  • Pre-commit hooks can be bypassed with --no-verify
  • No CI means no enforcement on PRs -- relies entirely on developer discipline
  • Doesn't address backend TypeScript codebase
  • Effort: 1-2 hours
  • Risk: Low effort, low risk. But also low coverage -- only catches syntactic issues, not logical bugs.
  • Description: Implement a complete quality gate pipeline:
  • Pre-commit: Husky + lint-staged runs ESLint and Prettier on staged files
  • Unit/Integration tests: Vitest + React Testing Library for frontend component tests
  • CI: GitHub Actions workflow runs lint + test + build on every PR and push to main
  • Branch protection: Require CI pass before merging to main
  • Implementation:

Frontend test setup:

npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/jest-dom
// vitest.config.js
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: ["./src/test/setup.js"],
    include: ["src/**/*.test.{js,jsx}"],
    coverage: {
      provider: "v8",
      reporter: ["text", "lcov"],
      include: ["src/api/**", "src/components/custom/**", "src/lib/**"],
    },
  },
  resolve: {
    alias: { "@": path.resolve(__dirname, "./src") },
  },
});

Example test (cache key validation):

// src/api/public/__tests__/blogs.test.js
import { describe, it, expect, vi } from "vitest";

describe("getBlogs", () => {
  it("returns different results for different parameters", async () => {
    // Mock fetch to return different data based on URL
    global.fetch = vi.fn((url) => {
      if (url.includes("page=1")) {
        return Promise.resolve({
          ok: true,
          json: () => Promise.resolve({ data: { blogs: [{ id: 1 }] } }),
        });
      }
      return Promise.resolve({
        ok: true,
        json: () => Promise.resolve({ data: { blogs: [{ id: 2 }] } }),
      });
    });

    const { getBlogs } = await import("../blogs");
    const page1 = await getBlogs({ page: 1 });
    const page2 = await getBlogs({ page: 2 });

    expect(page1).not.toEqual(page2);
  });
});

GitHub Actions workflow:

# .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  frontend:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: Sass-boilerplate-frontend-v1
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
          cache-dependency-path: Sass-boilerplate-frontend-v1/package-lock.json
      - run: npm ci
      - run: npm run lint
      - run: npx vitest run --coverage
      - run: npm run build

  backend:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: saas-boilerplate
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: yarn
          cache-dependency-path: saas-boilerplate/yarn.lock
      - run: yarn install --frozen-lockfile
      - run: yarn lint  # if lint script exists
      - run: yarn build # TypeScript compilation check

  • Pros:
  • Catches logic bugs: Tests like the cache key example above would have caught C1-C5 before they shipped
  • Catches style issues: ESLint with enhanced rules catches M5-M7, M14, M15
  • Enforced on PRs: GitHub Actions blocks merging broken code
  • Build verification: npm run build catches TypeScript/compilation errors, missing imports, SSR failures
  • Coverage tracking: Identifies untested areas; can set minimum thresholds
  • Boilerplate value: Adopters inherit a working CI pipeline -- huge time savings
  • Industry standard tooling: Vitest (fast, Vite-native), RTL (React standard), GitHub Actions (most popular CI)
  • Cons:
  • Largest initial investment (8-12 hours for full setup + initial test suite)
  • Tests must be maintained as code changes
  • GitHub Actions consumes CI minutes (free tier: 2,000 minutes/month for private repos)
  • Backend test setup is more complex (needs test database or mocking for TypeORM)
  • Vitest + Next.js 15 + React 19 may have compatibility quirks (RSC testing is still evolving)
  • Effort: 8-12 hours for initial setup. Ongoing: ~30 minutes per feature to write tests.
  • Risk: Medium. Initial setup is significant, but the long-term ROI is high. RSC component testing in Next.js 15 is still maturing -- start with utility/API tests and client component tests.

Option C: Full Pipeline + E2E Tests (Playwright)

  • Description: Everything in Option B plus Playwright end-to-end tests for critical user flows.
  • Implementation:
    npx playwright install
    
    // e2e/landing-page.spec.js
    import { test, expect } from "@playwright/test";
    
    test("landing page CTA buttons navigate to register", async ({ page }) => {
      await page.goto("/");
      await page.click('text="Start Free Trial"');
      await expect(page).toHaveURL("/register");
    });
    
    test("blog listing shows different content for different pages", async ({ page }) => {
      await page.goto("/blogs?page=1");
      const page1Content = await page.textContent("main");
      await page.goto("/blogs?page=2");
      const page2Content = await page.textContent("main");
      expect(page1Content).not.toEqual(page2Content);
    });
    
  • Pros:
  • Catches integration bugs that unit tests miss (e.g., CTA button navigation, cache behavior in real Next.js runtime)
  • Tests the full stack: frontend + backend + database
  • Playwright is the de facto standard for E2E testing
  • Visual regression testing capability
  • Cons:
  • Requires a running backend + database for E2E tests
  • Slow: E2E tests take 30-120 seconds vs. milliseconds for unit tests
  • Flaky test risk: E2E tests are inherently more fragile
  • Significant CI time and infrastructure cost
  • Maintenance burden: E2E tests break frequently with UI changes
  • Overkill for initial quality gate setup
  • Effort: 15-20 hours (Option B + E2E setup + test authoring)
  • Risk: Medium-High. E2E tests are valuable but should be added after unit tests are established.

Decision

Chosen Option: B -- Full Pipeline (Husky + ESLint + Vitest + GitHub Actions), with E2E (Option C) deferred to a later sprint

This option is chosen because:

  1. Addresses root cause. The 5 Whys analysis identified "zero quality gates" as the root cause of 4 systemic patterns. Option B installs quality gates at three levels: pre-commit (lint), CI (test + build), and PR review (branch protection).
  2. Right scope for a boilerplate. Unit tests + linting + CI is the standard baseline for professional projects. E2E tests (Option C) are valuable but can be added incrementally after the foundation is in place.
  3. Catches the actual bugs. A test for "different params return different results" would have caught C1-C5. A lint rule for no-console would have caught M5-M7. A lint rule for no-dupe-keys would have caught M14.
  4. Boilerplate value. Adopters forking the project get a working CI pipeline, test infrastructure, and lint rules out of the box. This is a significant time savings and sets quality expectations.
  5. Lint-only (Option A) is insufficient. Linting catches syntax issues but not logic bugs. The cache key bugs (the single most impactful class of errors in this analysis) are logic bugs that only tests can catch.

Consequences

  • Positive:
  • Future regressions of C1-C5 class bugs are caught by tests before merging
  • Console.log leaks (M5-M7) are caught by ESLint no-console rule
  • Duplicate keys (M14) are caught by ESLint no-dupe-keys rule
  • dangerouslySetInnerHTML usage is flagged by ESLint react/no-danger rule
  • Build failures (SSR crashes, missing imports) are caught by npm run build in CI
  • Adopters inherit a professional-grade quality setup
  • PR-based workflow prevents direct pushes of broken code to main
  • Negative:
  • 8-12 hours initial investment
  • Test maintenance burden as code evolves
  • CI minutes consumption (manageable on free tier for a boilerplate)
  • Backend test setup is deferred (no test database in initial CI) -- only TypeScript compilation check for now
  • Risks:
  • Vitest + Next.js 15 RSC testing is evolving. Start with API utility tests and client component tests; defer RSC-specific tests until tooling matures.
  • Pre-commit hooks can be bypassed with --no-verify. CI is the enforced backstop.

Implementation Notes

  1. Phase 1 (2-3 hours) -- Pre-commit hooks + ESLint:
  2. Install Husky and lint-staged in the frontend project
  3. Enhance eslint.config.mjs with no-console (warn), no-dupe-keys (error), react/no-danger (warn), no-unused-vars (warn)
  4. Add Prettier for consistent formatting (optional but recommended)
  5. Configure lint-staged to run on *.{js,jsx} files

  6. Phase 2 (3-4 hours) -- Vitest setup + initial tests:

  7. Install Vitest, @vitejs/plugin-react, jsdom, @testing-library/react, @testing-library/jest-dom
  8. Create vitest.config.js with path aliases matching Next.js
  9. Create src/test/setup.js for global test configuration
  10. Write initial tests targeting:
    • src/api/public/blogs.js -- cache key correctness, error handling
    • src/api/public/dynamic-page.js -- same
    • src/lib/utils.js -- utility function coverage
    • src/components/custom/safe-html.jsx -- sanitization behavior (after ADR-2)
  11. Add "test": "vitest run" and "test:watch": "vitest" scripts to package.json

  12. Phase 3 (2-3 hours) -- GitHub Actions CI:

  13. Create .github/workflows/ci.yml with frontend and backend jobs
  14. Frontend job: install, lint, test (with coverage), build
  15. Backend job: install, build (TypeScript compilation)
  16. Set up branch protection rules for main: require CI pass, require PR review

  17. Phase 4 (deferred) -- E2E tests:

  18. Install Playwright
  19. Write smoke tests for critical user flows (landing page, blog, contact)
  20. Add E2E job to GitHub Actions (runs after unit tests pass)

  21. Estimated total effort: 8-12 hours for Phases 1-3. Phase 4 is 5-8 additional hours, deferred.


Summary of Decisions

ADR Decision Effort Resolves
ADR-1 Migrate to fetch() with next.revalidate 3-4h C1-C5, H7, M8, M17
ADR-2 Dual-layer sanitization (backend write + frontend read) 4-5h C6, C7
ADR-3 Wire CTAs to /register + /contact, unify pricing data 3-4h H1, M9, M13, M14, M15, L14
ADR-4 Dynamic metadata + dynamic sitemap + notFound() 5-6h H2, H6, M1, M2, M3, M4
ADR-5 Husky + ESLint + Vitest + GitHub Actions CI pipeline 8-12h Root cause prevention
Total 23-31h 22 of 45 issues directly resolved + systemic prevention

Dependency Order

The ADRs can be implemented in parallel with the following exception:

  • ADR-5 (CI) should be set up first so that the changes from ADR-1 through ADR-4 go through the new quality pipeline. However, the quality gate setup should not block the Critical fixes (C1-C7). Pragmatic approach: implement ADR-1 and ADR-2 immediately (Critical severity), set up ADR-5 in parallel, then implement ADR-3 and ADR-4 through the new PR workflow.

Issues NOT Addressed by These ADRs

The following issues require separate decisions or are purely mechanical fixes that don't need architectural decisions:

  • H3 (Blog view count manipulation) -- needs rate-limiting design
  • H4 (Public endpoint serves unpublished content) -- needs backend query fix
  • H5 (Author query uses name string) -- needs backend API change or author entity
  • M5-M7 (console.log) -- resolved by ESLint rule in ADR-5, but existing occurrences need manual removal
  • M10 (No error boundary) -- needs error.js file creation (mechanical fix)
  • M11 (Catch-all route conflict) -- needs route restructuring consideration
  • M12 (Inconsistent field names) -- needs API response verification
  • M16 (Backend baseUrl construction) -- needs reverse proxy configuration
  • L1-L14 (Low severity) -- mechanical fixes, no architectural decision needed