Historical decision text is preserved. Accepted describes the decision, not deployment of every capability discussed. Consult the Architecture and Infrastructure tabs for the verified running system.
Date: 2026-05-10
Status: Accepted
Deciders: Daniel Meza
Context and Problem Statement
Urza's Wallet's SEO (Search Engine Optimization) strategy is the primary driver of organic user acquisition. Google's crawling and indexing of financial comparison pages is the revenue-generating engine. We need to decide — at the level of individual page types — whether pages are statically generated (SSG), statically generated with incremental revalidation (ISR), server-rendered per-request (SSR), or client-side rendered (CSR).
This decision has direct consequences for:
- Core Web Vitals: LCP (Largest Contentful Paint) must be < 2.5 s; SSG and ISR pages served from Railway's edge cache easily achieve LCP < 1.5 s. SSR pages add server processing time.
- Data freshness: Financial rates change infrequently (credit card rates: monthly; home loan rates: weekly; pension fund returns: quarterly). Pages must reflect the latest ingested data within a reasonable window.
- Search engine indexing: Google crawls public pages. Pages behind JavaScript (CSR) are crawled but with reduced priority. HTML pages (SSG/ISR/SSR) are crawled and indexed immediately.
- Canonical URL management: Comparison pages with dynamic query parameters must not create infinite indexable URL permutations.
- Server resource usage: SSR pages consume server CPU and memory on every request. ISR pages are served from cache after the first render. At Phase 1 traffic, this difference is small; at scale, it is significant.
Decision Drivers
- SEO-first principle: Every page that drives organic traffic must be fully rendered HTML before it reaches the browser. Google's mobile-first indexing penalizes pages with poor Core Web Vitals.
- ISR revalidation on ingestion: Next.js on-demand ISR (
revalidateTag/revalidatePath) allows pages to be refreshed immediately after a successful ingestion run, without waiting for a TTL (Time to Live)-based revalidation. This makes ISR effectively "fresh on change" rather than "fresh on a timer." - Comparison pages are dynamic by nature: A comparison page is defined by a user-selected set of 2–5 product IDs. There are thousands of possible permutations. Pre-generating all permutations at build time is infeasible. SSR (per-request rendering with fresh data) is the correct strategy.
- Canonical URL control: If comparison pages were SSG, each unique
?ids=permutation would be a separately cached page, potentially indexed. SSR withnoindexand a canonical pointing to the category hub avoids this. - Authenticated pages must never be indexed: User dashboard, saved comparisons, and admin pages contain personal data. CSR with
noindexis appropriate.
Considered Options
For each page type, the options are:
- SSG (build-time generation) — Pre-rendered at
next build, served as static HTML from Railway's edge cache. Does not update until the next build. - SSG + ISR (static with incremental revalidation) — Pre-rendered at build time; refreshed in the background when a request arrives after the TTL expires OR when
revalidatePathis called on-demand. - SSR (server-side rendering per-request) — Rendered on the server for every request. Always fresh. Higher server resource usage.
- CSR (client-side rendering) — Rendered in the browser using JavaScript. Not SEO-friendly for public pages.
Decision Outcome
The rendering strategy is assigned per page type as follows:
| Page Type | Strategy | Revalidation | Rationale |
|---|---|---|---|
| Homepage | SSG | On-demand only | Rarely changes; full CDN caching; maximum LCP performance |
Category hubs (/tarjetas-de-credito/) |
SSG + ISR | revalidate: 3600 + on-demand |
High SEO value; product list changes ≤ hourly; CDN-cached between updates |
Filter pages (/tarjetas-de-credito/sin-anualidad/) |
SSG + ISR | revalidate: 3600 + on-demand |
Same as category hub; pre-generated for common filters |
Issuer pages (/tarjetas-de-credito/bac-credomatic/) |
SSG + ISR | revalidate: 3600 + on-demand |
Issuer product lists stable; CDN-cached |
| Product detail pages | SSG + ISR | revalidate: 14400 + on-demand |
Highest conversion value; pre-rendered for LCP; rates change at most daily |
| Editorial / guide pages | SSG | On-demand only | Content changes rarely; full CDN caching |
Comparison pages (/comparar/tarjetas/?ids=...) |
SSR | Per-request | Dynamic product set; canonical control; noindex |
| User dashboard | CSR | — | Authenticated; personal data; noindex |
| Admin UI | CSR | — | Internal; authenticated; noindex |
ISR revalidation mechanics:
There are two revalidation triggers:
- TTL-based: Next.js automatically revalidates pages in the background when a request arrives after the configured
revalidateseconds have elapsed. The user who triggers the revalidation receives the cached (previous) version; subsequent users receive the fresh version. - On-demand: The Data Ingestion Service calls
POST /api/revalidatewith the affected vertical and product ID(s) after a successful write. Next.js callsrevalidatePath('/tarjetas-de-credito/')andrevalidatePath('/tarjetas-de-credito/bac-credomatic/bac-platinum/')for the affected pages. This means product pages are updated within ~10 seconds of a successful ingestion run, regardless of the TTL.
The on-demand revalidation endpoint is authenticated with an HMAC (Hash-based Message Authentication Code)-signed token (REVALIDATE_SECRET) shared between the ingestion service and the Next.js app. It is not a public endpoint.
Comparison page SSR — canonical strategy:
Comparison pages use SSR with the following behaviors:
- The
idsquery parameter is sorted and deduplicated server-side before rendering.?ids=def,abcand?ids=abc,defrender identically. - A
<meta name="robots" content="noindex, follow">tag is injected in the page<head>. - A
<link rel="canonical" href="/tarjetas-de-credito/">points to the parent category hub, not to the comparison URL. - Comparison pages are excluded from the sitemap.
- No internal links on the platform link to comparison URLs — they exist only as user-generated, shareable URLs (e.g., "compartir esta comparación" copies the URL to clipboard).
Build-time pre-generation scope:
generateStaticParams is limited to avoid excessively long build times:
- Category hubs and editorial pages: always generated (< 10 pages per vertical).
- Filter pages: generated for the top 10 filter combinations per vertical (most common search queries).
- Issuer pages: generated for all active issuers with ≥ 1 product (< 20 pages per vertical).
- Product detail pages: generated for the top 50 products per vertical by editorial score. Remaining products are generated on first request via ISR (they serve a "loading..." state for the first visitor, then cache).
Positive Consequences
- Maximum LCP for SEO-critical pages: ISR pages are served as static HTML from Railway's cache. LCP < 1.5 s for category and product detail pages.
- Data freshness bounded by ingestion cadence: On-demand revalidation ensures that product pages reflect the latest ingestion data within ~10 seconds of a successful ingestion run. The TTL-based fallback ensures pages are never older than 1–4 hours even if on-demand revalidation fails.
- No infinite URL space for comparison pages: SSR +
noindex+ canonical prevents comparison pages from polluting the sitemap or creating duplicate content issues. - Efficient server resource usage: ISR pages are served from cache for 99%+ of requests. SSR is used only for comparison pages (rare vs. category/product page traffic) and authenticated dashboard pages (not indexed).
- Clean canonical signal to Google: All indexed pages have a clear, unique canonical URL. No duplicate content from comparison URL permutations.
Negative Consequences / Trade-offs
- ISR serves stale content on TTL expiry: The first user to request a page after the TTL expires receives the cached (previous) version while revalidation happens in the background. This is acceptable for financial comparison data — a user seeing rates that are 59 minutes old is not a meaningful problem.
- Build time increases with product count: As the product catalog grows (from 200 to 2,000+ products), building all product detail pages at build time becomes slow. Mitigation: the
generateStaticParamslimit of 50 products per vertical at build time. Long-tail products are ISR-generated on first request. - SSR comparison pages add server load: Each comparison page request renders on the server. At low traffic (Phase 1), this is negligible. At scale, comparison pages may need a dedicated SSR instance or caching layer. The short-lived comparison snapshot cache (15 min, see ADR-005) reduces repeat SSR renders for the same product combination.
- On-demand revalidation dependency: If the ingestion service fails to call
/api/revalidate, pages are not updated until the TTL-based revalidation triggers. The TTL safety net (1–4 hours) ensures this is a bounded staleness window, not an indefinite one.
Pros and Cons of the Options (for product detail pages)
| Option | LCP | Data Freshness | Server Load | SEO Risk |
|---|---|---|---|---|
| SSG only | Best (< 1.5 s) | Stale until next build | Minimal | Low |
| SSG + ISR (chosen) | Best (< 1.5 s from cache) | Fresh within ingestion cycle | Low (cached responses) | Low |
| SSR | Good (< 2.5 s) | Always fresh | High (per-request render) | Low |
| CSR | Poor (JS-dependent) | Always fresh | Minimal (server) | High |
References
- NerdWallet reference architecture — ADR-001 (Hybrid SSR/SSG with Next.js)
- Next.js documentation: ISR, on-demand revalidation (
revalidatePath,revalidateTag),generateStaticParams - Google Search Central: Core Web Vitals, crawling JavaScript pages
- Architecture reference §6 (SEO Architecture), §3 (Container Diagram)