Architectural Tradeoffs in Next.js at Scale
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
revalidateseconds - 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
-
Start with rendering strategy per route. Document why each page uses SSR/ISR/CSR.
-
Monitor bundle size from day one. Set budgets, enforce with CI.
-
Build for on-demand ISR early. Don't pre-build everything.
-
Separate API from day one if you know you'll scale. Migration is painful.
-
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.