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¶
- ADR-1: Caching Architecture for Public Data Fetching
- ADR-2: HTML Sanitization Strategy for User-Generated Content
- ADR-3: CTA Button Wiring & Conversion Flow
- ADR-4: Public Site SEO Infrastructure
- 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"]withgetBlogs({ 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"]withgetDynamicPage("terms").getSettingByGroup("contact")shares cache key["settings-group"]withgetSettingByGroup("site").getNavigationLinks("navbar")shares cache key["navigation-links"]withgetNavigationLinks("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_cachebut fix the cache key arrays to include parameters. Fix error handling to return early on non-OK responses. - Implementation:
Critical Problem:
// 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"] } );unstable_cachecaptures the key array at definition time, not at call time. Theparamsvariable 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: - 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_cachemay change in Next.js 16+) - Creating a new
unstable_cachewrapper 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.
Option B: Migrate to fetch() with next.revalidate (Recommended)¶
- Description: Remove
unstable_cacheentirely. Use the nativefetch()API with Next.jsnext.revalidateoption, 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:
fetchwithnext.revalidateis the officially documented and stable caching mechanism in Next.js 15 - Automatic deduplication: Next.js automatically deduplicates
fetchcalls with the same URL in a single render pass, solving M8 (duplicate calls ingenerateMetadata+ 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.tagsworks withrevalidateTag()for on-demand cache invalidation - Simpler code: Plain
async functioninstead ofunstable_cachewrapper -- more readable, more testable - Error handling is natural: Early return on
!response.okis straightforward - No new dependencies
- Solves M17: The
navigation-links.jsmodule can be split -- server function in one file,useQueryhook 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.tagson the fetch options vs.tagsonunstable_cacheoptions) -- but functionally identical fetchcaching behavior can be surprising if developers forget to includenext.revalidate(defaults to no caching in Next.js 15'sdynamic = '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 usecacheTag()/cacheLife()for control. - Implementation:
- 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"requiresexperimental.dynamicIOflag and is explicitly canary. Not recommended for production. - Requires Next.js config changes (
next.config.mjsmust enableexperimental.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/redisorioredis) - 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:
- Correctness by design. The
fetchURL inherently includes all parameters, eliminating the class of "cache key missing parameters" bugs entirely. No manual key construction is needed. - 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. - Deduplication. Next.js automatically deduplicates
fetchcalls with the same URL in a single server render, solving M8 without any additional code. - Appropriate scope. Unlike Redis (Option D), this requires no new infrastructure and no new dependencies.
- 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
useQueryimport (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 everyfetchcall -- 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¶
- Migrate
blogs.js: Convert bothgetBlogsandgetBlogDetailsfromunstable_cachewrappers to plainasyncfunctions usingfetchwithnext.revalidateandnext.tags. Fix error handling to return early on!response.ok. - Migrate
dynamic-page.js: Same conversion forgetDynamicPage. - Migrate
settings.js: Same conversion forgetSettingByGroup. - Split
navigation-links.js: MovegetNavigationLinks(server) to one file anduseNavigationLinks(client hook usinguseQuery) to a separate file (e.g.,use-navigation-links.js). This resolves M17. - Create a shared constant for the revalidation duration to avoid repeating the
(parseInt(...) || 1) * 60 * 60expression in every file: - Test: Verify that different blog slugs, pagination pages, author filters, category filters, settings groups, and menu types all produce distinct cached responses.
- 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
dompurifyon the frontend. Create a<SafeHTML>wrapper component that sanitizes HTML before rendering it viadangerouslySetInnerHTML. - Implementation:
Usage: Replace
// 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 }} />; }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
dangerouslySetInnerHTMLwith<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.
Option B: Backend Sanitization on All Write Paths (Recommended Complement)¶
- Description: Extend the existing backend
DomPurifySanitizerto 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 fordangerouslySetInnerHTML-- can add an ESLint rule to flag directdangerouslySetInnerHTMLusage- 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-markdownwithrehype-sanitize) on the frontend. - Pros:
- Markdown is inherently safe -- no script injection possible
- Smaller content size in the database
react-markdown+rehype-sanitizeis 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:
- 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. - 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). - 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 directdangerouslySetInnerHTMLusage). - Practical effort. 4-5 hours is reasonable for eliminating 2 Critical-severity XSS vulnerabilities.
- 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
dangerouslySetInnerHTMLlocations 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-syntaxfordangerouslySetInnerHTMLto 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, considerisomorphic-dompurifywhich works in Node.js via JSDOM, or usesanitize-html(already a backend dependency) on the server and DOMPurify on the client.
Implementation Notes¶
- Frontend -- Create
<SafeHTML>component: - File:
Sass-boilerplate-frontend-v1/src/components/custom/safe-html.jsx - Install:
npm install dompurify(orisomorphic-dompurifyfor SSR compatibility) - Match the allowed-tags list to the backend
DomPurifySanitizerconfig insaas-boilerplate/src/app/infrastructure/sanitizer/dompurify.sanitizer.ts - Frontend -- Replace
dangerouslySetInnerHTMLusage: blog-page-frontend.jsx:94-- replace with<SafeHTML html={blog.content} className="blog-content" />dynamic-page.jsx:20-- replace with<SafeHTML html={data?.description} className="blog-content" />email-templates-page.jsx:805-- replace (admin-only, lower priority)email-preview.jsx:288,292,297-- replace (admin-only, lower priority)- Backend -- Add sanitization to GenericPage service:
- File:
saas-boilerplate/src/app/modules/v1/GenericPage/generic-page.service.ts - Add
SanitizerFactory("dompurify")calls increatePageandupdatePagemethods for thedescriptionfield - Backend -- Add sanitization to EmailTemplate service (lower priority, admin-only content):
- File:
saas-boilerplate/src/app/modules/v1/EmailTemplate/email-template.service.ts - Shared configuration: Document the canonical allowed-tags list in a code comment or shared constant referenced by both frontend and backend configs.
- 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:
For pricing buttons, use
// 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>ui/button.jsxwithasChild:Update<Button asChild className="w-full mt-auto rounded-full" variant={plan.popular ? "default" : "outline"}> <Link href={plan.ctaLink}>{plan.cta}</Link> </Button>landing-page-data.js: - Pros:
- Simple, immediate fix -- all buttons become functional
/registeris the natural conversion endpoint for a SaaS boilerplate- Passing
?plan=starterin the URL allows the registration page to pre-select a plan - "Book a Demo" pointing to
/contactleverages the existing contact form - Single source of truth for pricing data (monthly and annual in one object)
- Easy to customize: adopters just change
ctaLinkvalues - 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.jsconstants file. This allows adopters to change all CTA behavior from a single file without modifying components. - Implementation:
Components read from this config:
// 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" }, }, }; - 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.
Option C: Stripe Checkout Direct Links¶
- 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:
- Functional out-of-the-box.
/registerand/contactalready exist and work. No new pages or backend changes needed. - Easy to customize. All CTA labels and links are in
landing-page-data.jsalongside pricing, features, testimonials, and FAQs. One file to edit. - Pricing unification. A single
pricingPlansarray with bothmonthlyPriceandannualPriceeliminates the inline annual pricing duplication (M9). - Component cleanup. Standardize on
ui/button.jsx(Radix-based, supportsasChildfor links) and phase outcustom/button.jsxfrom landing page sections. This resolves M13, M14, and M15. - 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.jsxbugs (M14, M15) bypassed by usingui/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.
/registerpage does not currently handle?plan=query parameter for plan pre-selection (nice-to-have, not a blocker)- Risks:
- If
/registeris broken or API is down, all CTAs lead to an error page. Mitigated by error boundaries (addressed separately).
Implementation Notes¶
- Update
landing-page-data.js: - Add
ctaLinkfield to each pricing plan - Add
monthlyPrice(number) andannualPrice(number) fields; compute display strings from these - Add top-level CTA config:
- Update
hero-section.jsx: Wrap buttons in<Link>usingctaActions. Replacecustom/button.jsxwithui/button.jsx. - Update
cta-section.jsx: Same treatment. UsectaActions.secondary.labelto unify "Book a Demo" / "Schedule a Demo" (L14). - Update
pricing-section.jsx: Remove inline annual pricing array. Compute annual tab frompricingPlans[].annualPrice. Wrap pricing buttons in<Link href={plan.ctaLink}>. - Verify
/registerpage loads correctly when accessed from landing page CTAs. - 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.
- 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:
- Missing metadata (4 of 7 routes):
- Homepage (
/) -- nogenerateMetadata(H2). The most important page for SEO relies entirely on root layout defaults. - Author pages (
/author/[slug]) -- nogenerateMetadata(M2) - Category pages (
/category/[slug]) -- nogenerateMetadata(M3) -
Contact page (
/contact) -- nogenerateMetadata(M4) -
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. -
Broken title template (H6): When the API is down,
fetchMetaDatainlayout.jsreturns the string"SASS Boilerplate"instead of an object.generateMetadataaccessessettings?.admin?.site_nameon this string, producingundefined. All child page titles render as "Blogs | undefined". -
No
robots.txtorsitemap.xml: Search engines have no crawl directives and no sitemap for efficient indexing. -
No canonical URLs: Pages accessible via multiple paths (e.g.,
/blogs?page=1and/blogs) are not de-duplicated for search engines. -
Inconsistent metadata field names (M12):
generateMetadatainblogs/[slug]/page.jsusesblogData?.imagebut the component usesblog?.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 = { ... }orexport function generateMetadata()to each of the 4 pages missing it. Add staticrobots.txtandsitemap.xmlfiles. Fix the 404 HTTP status by usingnotFound()fromnext/navigation. - Implementation:
For 404 fix:
// (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// 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 } // ... };robots.txt: - 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.xmlwith hardcoded URLs won't include dynamic blog posts or pages- Effort: 3-4 hours
- Risk: Low.
Option B: Dynamic Metadata + Dynamic Sitemap (Recommended)¶
- Description: Add
generateMetadatato all 4 missing pages. Where applicable (author, category), fetch relevant data for rich metadata. Create a dynamicsitemap.tsthat queries the backend for all published blog slugs and dynamic page slugs. Createrobots.ts. Fix 404 status withnotFound(). Fix thefetchMetaDataerror handler. - Implementation:
For dynamic sitemap:
// 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", }, }; }Fix// 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]; }fetchMetaDataerror handler: - Pros:
- Rich, dynamic metadata on every public page
- Dynamic sitemap includes all published blog posts -- critical for blog SEO
- Fixes the
fetchMetaDataerror handler that causes "undefined" in title template (H6) notFound()returns proper HTTP 404 status (M1)- Follows Next.js 15 conventions (
sitemap.js,robots.jsas 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
MetadataServicethat 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-Tagheaders, 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:
- 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. - Dynamic sitemap is essential. A blog-driven SaaS boilerplate needs its blog posts indexed. A static sitemap cannot include dynamically created content.
- Standard Next.js patterns.
generateMetadata,sitemap.js,robots.js, andnotFound()are all built-in Next.js 15 features. No custom abstractions needed. - Fixes the "undefined" title bug (H6). The
fetchMetaDataerror handler fix ensures child page titles never show "| undefined". - 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.txtdirects crawlers away from/dashboard/and points to sitemapsitemap.xmldynamically 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_URLenvironment variable must be set correctly for sitemap and canonical URLs
Implementation Notes¶
- Fix
fetchMetaDataerror handler inSass-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. - Add
generateMetadatato 4 pages: (main-layout)/page.js-- static metadata for homepageauthor/[slug]/page.js-- derive from slugcategory/[slug]/page.js-- derive from slugcontact/page.js-- static metadata- Fix 404 HTTP status in
blogs/[slug]/page.jsand[slug]/page.js: Replacereturn <NotFound />withnotFound()fromnext/navigation. This triggers the proper Next.js 404 mechanism. - Create
src/app/robots.js: Export a function returning crawl rules. Disallow/dashboard/. - Create
src/app/sitemap.js: Export an async function that fetches blog slugs from the public API and generates URL entries. - Add
NEXT_PUBLIC_SITE_URLto.env.example: Document it as required for SEO features. - 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:
- Pattern A (5 identical cache bugs): Copy-paste development with no tests to catch the error propagating.
- Pattern B (4 unsanitized HTML locations): No security linting or XSS detection rules.
- Pattern C (3 console.log in production): No
no-consoleESLint rule. - 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):Husky + lint-staged setup: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: "^_" }], }, }, ]; - Pros:
- Prevents future regressions:
no-consolecatches M5/M6/M7,no-dupe-keyscatches M14,react/no-dangerflags 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.
Option B: Full Pipeline -- Husky + ESLint + Vitest + GitHub Actions (Recommended)¶
- 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:
// 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 buildcatches 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:
// 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:
- 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).
- 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.
- Catches the actual bugs. A test for "different params return different results" would have caught C1-C5. A lint rule for
no-consolewould have caught M5-M7. A lint rule forno-dupe-keyswould have caught M14. - 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.
- 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-consolerule - Duplicate keys (M14) are caught by ESLint
no-dupe-keysrule dangerouslySetInnerHTMLusage is flagged by ESLintreact/no-dangerrule- Build failures (SSR crashes, missing imports) are caught by
npm run buildin 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¶
- Phase 1 (2-3 hours) -- Pre-commit hooks + ESLint:
- Install Husky and lint-staged in the frontend project
- Enhance
eslint.config.mjswithno-console(warn),no-dupe-keys(error),react/no-danger(warn),no-unused-vars(warn) - Add Prettier for consistent formatting (optional but recommended)
-
Configure lint-staged to run on
*.{js,jsx}files -
Phase 2 (3-4 hours) -- Vitest setup + initial tests:
- Install Vitest, @vitejs/plugin-react, jsdom, @testing-library/react, @testing-library/jest-dom
- Create
vitest.config.jswith path aliases matching Next.js - Create
src/test/setup.jsfor global test configuration - Write initial tests targeting:
src/api/public/blogs.js-- cache key correctness, error handlingsrc/api/public/dynamic-page.js-- samesrc/lib/utils.js-- utility function coveragesrc/components/custom/safe-html.jsx-- sanitization behavior (after ADR-2)
-
Add
"test": "vitest run"and"test:watch": "vitest"scripts topackage.json -
Phase 3 (2-3 hours) -- GitHub Actions CI:
- Create
.github/workflows/ci.ymlwith frontend and backend jobs - Frontend job: install, lint, test (with coverage), build
- Backend job: install, build (TypeScript compilation)
-
Set up branch protection rules for
main: require CI pass, require PR review -
Phase 4 (deferred) -- E2E tests:
- Install Playwright
- Write smoke tests for critical user flows (landing page, blog, contact)
-
Add E2E job to GitHub Actions (runs after unit tests pass)
-
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.jsfile 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