← Back to blog

Architectural Tradeoffs in Next.js at Scale

8 min read
Next.jsArchitecturePerformanceScalability

Architectural Tradeoffs in Next.js at Scale

Next.js makes it easy to build fast applications. It makes it hard to keep them fast at scale.

The decisions you make on day one—SSR vs ISR vs CSR, app structure, API boundaries—compound over time. What works for 100 pages breaks at 10,000. What's fast with 10 users crawls with 10,000.

After scaling multiple Next.js apps to production, I've learned that the framework's flexibility is both its strength and its trap.

Context: When "Fast by Default" Stops Being True

Next.js is genuinely fast out of the box. But "at scale" changes everything:

At small scale:

  • SSR everything, it's fast
  • Co-locate API routes with pages
  • Shared components are convenient
  • Build times are negligible

At large scale:

  • SSR becomes a bottleneck
  • API routes cause cold starts
  • Shared components cause massive bundles
  • Build times hit 10+ minutes

The patterns that get you to MVP actively harm you at scale.

Why This Matters in Production

In a large Next.js application I worked on:

  • Build times: 15 minutes (blocking deploys)
  • First Load JS: 400KB (slow on mobile)
  • Server costs: 3x expected (SSR overhead)
  • Cold starts: 2-3s on API routes (poor UX)

These weren't Next.js problems. They were architectural decisions we made early that compounded.

Rendering Strategy: The Foundational Choice

SSR (Server-Side Rendering)

When it works:

// ✅ Good for SSR: Personalized, dynamic content
export async function getServerSideProps(context) {
  const user = await getUser(context.req)
  const recommendations = await getRecommendations(user.id)
  
  return {
    props: { user, recommendations },
  }
}

Best for:

  • Personalized content (user-specific)
  • Real-time data (stock prices, inventory)
  • SEO-critical with dynamic data
  • Authentication-dependent pages

Costs:

  • Server CPU on every request
  • Can't cache at CDN
  • Slower TTFB than static
  • Scales with traffic ($$)

What breaks at scale:

  • High traffic = high server costs
  • Database queries on every request
  • External API calls block rendering
  • No caching benefits

ISR (Incremental Static Regeneration)

When it works:

// ✅ Good for ISR: Semi-static content
export async function getStaticProps() {
  const products = await fetchProducts()
  
  return {
    props: { products },
    revalidate: 60, // Regenerate every 60 seconds
  }
}

Best for:

  • Product catalogs (updates hourly/daily)
  • Blog posts (static after published)
  • Marketing pages (occasional updates)
  • High traffic, low change rate

Costs:

  • Stale data for up to revalidate seconds
  • First user after revalidate sees old page
  • Regeneration happens in background
  • Cache invalidation is tricky

What breaks at scale:

  • 10K pages = 10K regenerations
  • Cascading regenerations (related pages)
  • Hard to invalidate specific pages
  • Build times still increase with page count

CSR (Client-Side Rendering)

When it works:

// ✅ Good for CSR: Client-only interactivity
export default function Dashboard() {
  const { data } = useSWR('/api/user-data', fetcher)
  
  if (!data) return <Loading />
  
  return <DashboardUI data={data} />
}

Best for:

  • Authenticated dashboards
  • Real-time feeds (social, monitoring)
  • Heavy interactivity (editors, builders)
  • Doesn't need SEO

Costs:

  • No SEO for JS-rendered content
  • Slow initial render on slow devices
  • Flash of loading state
  • Larger client bundle

What breaks at scale:

  • Bundle size grows with features
  • Cumulative Layout Shift (CLS) issues
  • Poor Core Web Vitals
  • Mobile performance suffers

The Decision Matrix

                    SEO    Real-time   Personal   Traffic   Cost
SSR                 ✅      ✅          ✅        High      $$$$
ISR                 ✅      ❌          ❌        Low       $$
CSR                 ❌      ✅          ✅        N/A       $
Static              ✅      ❌          ❌        N/A       $

Legend:
✅ = Excellent
❌ = Poor
$ = Low cost
$$$$ = High cost

My rule of thumb:

  • Public marketing → Static or ISR
  • Product pages → ISR
  • User profiles → SSR
  • Dashboards → CSR
  • Real-time feeds → CSR + API

Performance Bottlenecks at Scale

1. Hydration Cost

// ❌ Hydrating massive component trees
export async function getServerSideProps() {
  const products = await fetchProducts() // 1000 products
  
  return {
    props: { products },
  }
}

export default function Products({ products }) {
  // React hydrates 1000 product cards
  // Costs hundreds of milliseconds on mobile
  return products.map(p => <ProductCard product={p} />)
}

The problem:

  • SSR sends HTML + JSON data
  • React reconstructs virtual DOM
  • Attaches event handlers
  • Heavy on CPU and memory

At scale:

  • 100KB HTML + 200KB JSON = slow
  • Hydration takes 500ms on mobile
  • Poor Time to Interactive (TTI)
  • Users see content but can't interact

Solution:

// ✅ Reduce hydration cost
export default function Products({ initialProducts }) {
  return (
    <>
      {/* Static content, no hydration needed */}
      <ProductGrid products={initialProducts} />
      
      {/* Interactive parts only */}
      <ClientOnlyFilters />
    </>
  )
}

function ClientOnlyFilters() {
  // Only this hydrates, much smaller cost
  const [filters, setFilters] = useState({})
  return <Filters value={filters} onChange={setFilters} />
}

2. Bundle Size Explosion

// ❌ Everything in app bundle
import { format } from 'date-fns' // +50KB
import { Chart } from 'chart.js' // +200KB
import _ from 'lodash' // +72KB

export default function Analytics() {
  return <Chart data={data} />
}

At scale:

  • First Load JS: 500KB+
  • Slow load on 3G
  • Poor Lighthouse scores
  • High bounce rate

Solution:

// ✅ Aggressive code splitting
import dynamic from 'next/dynamic'

const Chart = dynamic(() => import('./Chart'), {
  loading: () => <ChartSkeleton />,
  ssr: false, // Don't render on server
})

// Or import specific functions
import { format } from 'date-fns/format' // +2KB instead of +50KB

export default function Analytics() {
  return <Chart data={data} />
}

3. API Route Cold Starts

// ❌ API route for every request
// pages/api/user.ts
export default async function handler(req, res) {
  const user = await db.user.findUnique({ where: { id: req.query.id } })
  res.json(user)
}

At scale:

  • Serverless functions have cold starts
  • Database connections pool exhaustion
  • No request deduplication
  • Costs scale with usage

Solution:

// ✅ Separate API + caching layer
// Move to dedicated API service (Express, Fastify)
// Or use Next.js with caching

export default async function handler(req, res) {
  const cached = await redis.get(`user:${req.query.id}`)
  if (cached) return res.json(cached)
  
  const user = await db.user.findUnique({ where: { id: req.query.id } })
  await redis.set(`user:${req.query.id}`, user, 'EX', 300)
  
  res.json(user)
}

4. Build Time Growth

At 1,000 pages:

  • Build time: 5 minutes

At 10,000 pages:

  • Build time: 45 minutes
  • CI/CD pipeline blocks
  • Can't deploy quickly
  • Developer velocity tanks

Solution:

// Use on-demand ISR instead of building all pages
export async function getStaticPaths() {
  // Only build most popular pages
  const popularProducts = await getPopularProducts(100)
  
  return {
    paths: popularProducts.map(p => ({ params: { id: p.id } })),
    fallback: 'blocking', // Generate others on-demand
  }
}

Architecture Patterns That Scale

1. Hybrid Rendering Strategy

// app/
// ├── marketing/        → Static/ISR (SEO, public)
// ├── product/          → ISR (catalog, semi-static)
// ├── dashboard/        → CSR (authenticated, real-time)
// └── profile/          → SSR (personalized, SEO)

// Don't force one strategy everywhere

2. Separate API Layer

// ❌ Don't do this at scale
pages/api/users/[id].ts
pages/api/products/[id].ts
pages/api/orders/[id].ts

// ✅ Do this at scale
// Separate backend (Express, NestJS, etc.)
// Next.js only for UI rendering
// API communicates via REST/GraphQL

Why:

  • Dedicated API can scale independently
  • Better caching strategies
  • No cold start issues
  • Can use different deployment targets

3. Edge for Static, Server for Dynamic

// Serve static assets and ISR pages from edge
// CDN caches globally

// Serve SSR and API routes from origin
// Co-located with database

Benefits:

  • Static content is instant globally
  • Dynamic content stays near data
  • Reduced server costs
  • Better performance worldwide

Tradeoffs

SSR everywhere:

  • ✅ Great SEO, fresh data
  • ❌ High server costs, slow TTFB

ISR everywhere:

  • ✅ Low server costs, fast
  • ❌ Stale data, cache invalidation complexity

CSR everywhere:

  • ✅ Simple architecture, low cost
  • ❌ Poor SEO, slow initial render

Separate API:

  • ✅ Scales better, more control
  • ❌ More infrastructure, complexity

What I'd Do Differently Today

  1. Start with rendering strategy per route. Document why each page uses SSR/ISR/CSR.

  2. Monitor bundle size from day one. Set budgets, enforce with CI.

  3. Build for on-demand ISR early. Don't pre-build everything.

  4. Separate API from day one if you know you'll scale. Migration is painful.

  5. Measure real user metrics. Core Web Vitals, not Lighthouse.

Next.js gives you tools. It doesn't tell you when to use them. The framework scales. Your architecture might not.

Most performance problems in Next.js apps aren't Next.js problems. They're architecture decisions that made sense for 100 pages but break at 10,000.

Design for the scale you'll be, not the scale you are.

Because by the time you feel the pain, migration is a 6-month project.