DEV Community

Javier
Javier

Posted on • Originally published at valuemarkers.com

ISR vs SSR vs CSR: Performance Lessons from Building a Financial Data App with Next.js 14

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} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. The standalone build matters. Next.js output: 'standalone' creates a minimal production bundle. After build, copy .next/static and public into .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
Enter fullscreen mode Exit fullscreen mode

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)