When you're serving stock data for 100,000+ companies across 73 exchanges, rendering strategy isn't academic - it's the difference between a fast app and an unusable one. Here's what we learned building a financial data platform with Next.js 14.
The Challenge
Our app serves fundamentally different types of pages:
-
Stock detail pages (
/stock/[ticker]): 100,000+ pages, data changes every 6 hours -
Screener page (
/screener): Highly interactive, user-driven filters -
Leaderboard (
/leaderboard): Changes hourly, consumed by many users -
Blog posts (
/blog/[slug]): Rarely changes, needs SEO
Each page type demands a different rendering approach.
Our Rendering Matrix
| Page | Strategy | Revalidate | Why |
|---|---|---|---|
/stock/[ticker] |
ISR | 6 hours | 100K pages, data changes daily |
/leaderboard |
ISR | 1 hour | Changes frequently, many readers |
/blog/[slug] |
ISR (Sanity CMS) | 1 hour | SEO-critical, CMS-driven |
/screener |
CSR | - | Highly interactive, user-specific |
| Auth pages | CSR | - | User-specific, no SEO need |
/ (home) |
SSR + Islands | - | Mix of static and dynamic |
ISR for Stock Pages: The 100K Page Problem
With 100,000+ stocks, we can't pre-render everything at build time. ISR with on-demand revalidation is the solution:
// app/stock/[ticker]/page.tsx
export const revalidate = 21600; // 6 hours
export async function generateStaticParams() {
// Only pre-render the top 1000 most-viewed stocks
const topStocks = await getTopStocks(1000);
return topStocks.map(s => ({ ticker: s.ticker }));
}
export default async function StockPage({ params }) {
const { ticker } = await params;
const stock = await fetchStockData(ticker);
if (!stock) return notFound();
return (
<>
<StockHeader data={stock} />
<IndicatorGrid indicators={stock.indicators} />
{/* Client components for interactive tabs */}
<StockTabs ticker={ticker} />
</>
);
}
The remaining 99,000 stocks are rendered on first request and cached for 6 hours. This means:
- First visitor to an obscure ticker waits ~800ms (SSR)
- Every subsequent visitor gets the cached page in ~50ms
- After 6 hours, the next visitor triggers a background revalidation
- The stale page is served instantly while the new one generates
CSR for the Screener: When Interactivity Wins
The screener is our most interactive page. Users build complex filter groups with AND/OR logic, adjust ranges with sliders, and see results update in real-time. SSR makes no sense here.
// app/screener/page.tsx
'use client';
export default function ScreenerPage() {
const [filters, setFilters] = useState<FilterGroup[]>([]);
const [results, setResults] = useState<Stock[]>([]);
// Debounced API call on filter change
useEffect(() => {
const timer = setTimeout(() => {
searchStocks(filters).then(setResults);
}, 300);
return () => clearTimeout(timer);
}, [filters]);
return (
<>
<FilterBuilder filters={filters} onChange={setFilters} />
<ResultsTable stocks={results} />
</>
);
}
But CSR pages need SEO too. We solve this with a layout.tsx:
// app/screener/layout.tsx
export const metadata = {
title: 'Stock Screener - 120+ Fundamental Indicators',
description: 'Screen 100,000+ stocks across 73 exchanges...',
};
export default function ScreenerLayout({ children }) {
return children;
}
The Server + Client Island Pattern
Our home page uses the "island architecture" - a server-rendered shell with client-side interactive islands:
// app/page.tsx (Server Component)
export default async function HomePage() {
const stats = await getMarketStats(); // Server-side fetch
return (
<main>
{/* Static server-rendered content for SEO */}
<HeroSection stats={stats} />
<FeatureGrid />
{/* Client island - interactive email capture */}
<HeroEmailCapture />
{/* More static content */}
<TrustIndicators />
{/* Client island - interactive search */}
<HomeClientIsland />
</main>
);
}
The server components ship zero JavaScript. The client islands hydrate independently. The page loads fast, is fully crawlable, and the interactive parts work as soon as they hydrate.
Performance Results
After implementing this rendering strategy:
| Metric | Before (all CSR) | After (mixed) | Improvement |
|---|---|---|---|
| LCP (stock pages) | 3.2s | 0.8s | 75% faster |
| FCP (home) | 2.1s | 0.6s | 71% faster |
| JS bundle (screener) | 420KB | 180KB | 57% smaller |
| Crawled pages (GSC) | 28 | 25,000+ | 892x more |
The biggest win was crawlability. When everything was CSR, Google only indexed 28 pages. After switching stock pages to ISR, Google started discovering and indexing all 100,000+ stock pages.
Lessons Learned
ISR is the default for data-driven pages. Unless you have a reason not to, use ISR. The caching layer is essentially free and the revalidation is invisible to users.
CSR only when you must. Save client-side rendering for genuinely interactive features: search, filters, forms, real-time data. Everything else should render on the server.
Metadata lives in layout.tsx for CSR pages. Client components can't export metadata, but their layout can. This is how you get SEO for interactive pages.
Pre-render your head, lazy-render the tail. For large catalogs, pre-render the top N pages at build time and let ISR handle the rest on-demand. We pre-render 1,000 stocks and let 99,000+ generate lazily.
The standalone build matters. Next.js
output: 'standalone'creates a minimal production bundle. After build, copy.next/staticandpublicinto.next/standalone/or your static assets won't work.
The Architecture Decision Framework
Ask these questions for each page:
Is it interactive? (filters, real-time updates, user-specific)
→ YES → CSR ('use client')
→ NO → Continue...
Does it need fresh data?
→ Every request → SSR (no cache)
→ Periodically → ISR (set revalidate)
→ Rarely/never → Static (build-time)
Does it mix static and dynamic?
→ YES → Server component + client islands
I'm Javier Sanz, a software engineer building ValueMarkers - a stock analysis platform serving 100K+ stocks across 73 global exchanges with Next.js 14, FastAPI, and PostgreSQL.
Top comments (0)