Building Scalable Web Applications with Next.js and TypeScript
Building Scalable Web Applications with Next.js and TypeScript
A practical guide to architecting applications that handle millions of users without breaking a sweat.
Scaling web applications is one of the most challenging aspects of modern software development. At Yetti LLC, we've helped dozens of startups and enterprises build systems that scale from zero to millions of users. Here's everything we've learned about building scalable applications with Next.js and TypeScript.
Why Next.js for Scalability?
Next.js has become our framework of choice for scalable applications because it provides the perfect balance of developer experience and production-ready features:
Built-In Performance Optimization
- Automatic code splitting for faster page loads
- Image optimization reducing bandwidth by 60%+
- Font optimization with automatic subset generation
- Bundle analysis tools built into the framework
Flexible Rendering Strategies
- Static Site Generation (SSG) for marketing pages
- Server-Side Rendering (SSR) for dynamic content
- Incremental Static Regeneration (ISR) for the best of both
- Client-Side Rendering (CSR) when needed
Production-Ready Architecture
- API routes for serverless backend logic
- Edge runtime for global performance
- Built-in TypeScript support
- Enterprise-grade security features
Architecture Principles for Scale
1. Design for Horizontal Scaling
The key to scaling is ensuring your application can grow by adding more servers, not bigger servers.
// ❌ Don't: Store session state in memory
const sessions = new Map<string, UserSession>();
app.post('/api/login', async (req, res) => {
const session = { userId: user.id, expires: Date.now() + 3600000 };
sessions.set(sessionId, session); // Lost when server restarts!
res.json({ sessionId });
});
// ✅ Do: Use distributed session storage
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
app.post('/api/login', async (req, res) => {
const session = { userId: user.id, expires: Date.now() + 3600000 };
await redis.setex(sessionId, 3600, JSON.stringify(session));
res.json({ sessionId });
});
Key Principles:
- Stateless application servers
- Externalized session storage (Redis, DynamoDB)
- Distributed caching layers
- Database connection pooling
- Load balancer health checks
2. Implement Effective Caching Strategy
Caching is the single most effective way to improve scalability. We use a multi-layer approach:
// Multi-layer caching implementation
import { LRUCache } from 'lru-cache';
import { Redis } from 'ioredis';
class CacheManager {
private memoryCache: LRUCache<string, any>;
private redis: Redis;
constructor() {
// L1: In-memory cache (fastest, smallest)
this.memoryCache = new LRUCache({
max: 500,
ttl: 1000 * 60 * 5, // 5 minutes
});
// L2: Redis cache (fast, shared across servers)
this.redis = new Redis(process.env.REDIS_URL);
}
async get<T>(key: string): Promise<T | null> {
// Check L1 cache first
const memResult = this.memoryCache.get(key);
if (memResult) {
console.log('Cache hit: Memory');
return memResult as T;
}
// Check L2 cache
const redisResult = await this.redis.get(key);
if (redisResult) {
console.log('Cache hit: Redis');
const parsed = JSON.parse(redisResult);
this.memoryCache.set(key, parsed); // Promote to L1
return parsed as T;
}
console.log('Cache miss');
return null;
}
async set(key: string, value: any, ttl: number = 300): Promise<void> {
// Write to both caches
this.memoryCache.set(key, value);
await this.redis.setex(key, ttl, JSON.stringify(value));
}
}
// Usage in API routes
export async function GET(request: Request) {
const cache = new CacheManager();
const cacheKey = `user-${userId}`;
// Try cache first
let user = await cache.get(cacheKey);
if (!user) {
// Cache miss - query database
user = await prisma.user.findUnique({ where: { id: userId } });
await cache.set(cacheKey, user, 600); // Cache for 10 minutes
}
return Response.json(user);
}
Caching Strategies We Use:
- Static assets: CDN with long cache headers (1 year)
- API responses: Redis with smart invalidation
- Database queries: Query result caching with Prisma
- Page renders: ISR for semi-static content
- Images: Next.js Image Optimization with CDN
3. Database Optimization
Database queries are often the bottleneck in scalable applications.
// ❌ Don't: N+1 query problem
async function getBlogPostsWithAuthors() {
const posts = await prisma.post.findMany();
// This creates N additional queries!
const postsWithAuthors = await Promise.all(
posts.map(async (post) => ({
...post,
author: await prisma.user.findUnique({ where: { id: post.authorId } })
}))
);
return postsWithAuthors;
}
// ✅ Do: Use includes/joins
async function getBlogPostsWithAuthors() {
const posts = await prisma.post.findMany({
include: {
author: true, // Single query with JOIN
},
});
return posts;
}
// ✅ Even better: Use select to only fetch needed fields
async function getBlogPostsWithAuthors() {
const posts = await prisma.post.findMany({
select: {
id: true,
title: true,
excerpt: true,
author: {
select: {
id: true,
name: true,
avatar: true,
},
},
},
});
return posts;
}
Database Best Practices:
- Proper indexing on frequently queried columns
- Connection pooling with PgBouncer
- Read replicas for heavy read workloads
- Query result pagination
- Denormalization for read-heavy tables
- Database query monitoring with explain analyze
4. API Route Optimization
API routes should be fast, reliable, and handle errors gracefully.
// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { rateLimit } from '@/lib/rate-limit';
import { authenticate } from '@/lib/auth';
// Validation schema
const UserParamsSchema = z.object({
id: z.string().uuid(),
});
// Rate limiting (100 requests per 10 minutes)
const limiter = rateLimit({
interval: 10 * 60 * 1000,
uniqueTokenPerInterval: 500,
});
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
// Rate limiting
await limiter.check(request, 100);
// Authentication
const user = await authenticate(request);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Validation
const validatedParams = UserParamsSchema.parse(params);
// Database query with caching
const cachedUser = await cache.get(`user:${validatedParams.id}`);
if (cachedUser) {
return NextResponse.json(cachedUser);
}
const userData = await prisma.user.findUnique({
where: { id: validatedParams.id },
select: {
id: true,
name: true,
email: true,
avatar: true,
// Don't expose sensitive fields
},
});
if (!userData) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Cache the result
await cache.set(`user:${validatedParams.id}`, userData, 300);
return NextResponse.json(userData);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid parameters', details: error.errors },
{ status: 400 }
);
}
console.error('API Error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
API Best Practices:
- Input validation with Zod or similar
- Rate limiting to prevent abuse
- Authentication and authorization
- Proper error handling
- Response caching
- Request timeout handling
- Structured logging
Performance Optimization Techniques
1. Image Optimization
Images account for 50%+ of page weight on average. Next.js Image component handles this automatically:
import Image from 'next/image';
// ✅ Optimized image loading
export function ProductCard({ product }) {
return (
<div className="product-card">
<Image
src={product.imageUrl}
alt={product.name}
width={400}
height={300}
placeholder="blur"
blurDataURL={product.blurDataUrl}
quality={85}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 400px"
loading="lazy"
/>
</div>
);
}
Image Optimization Benefits:
- Automatic WebP/AVIF format selection
- Responsive images with srcset
- Lazy loading out of the box
- Blur placeholder for better UX
- CDN delivery via Vercel
2. Code Splitting
Split your JavaScript bundles to load only what's needed:
// Dynamic imports for code splitting
import dynamic from 'next/dynamic';
// Heavy component loaded only when needed
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
loading: () => <ChartSkeleton />,
ssr: false, // Don't server-render this component
});
// Modal loaded on demand
const Modal = dynamic(() => import('@/components/Modal'));
export function Dashboard() {
const [showModal, setShowModal] = useState(false);
return (
<div>
<HeavyChart data={data} />
{showModal && <Modal onClose={() => setShowModal(false)} />}
</div>
);
}
3. React Server Components
Use Server Components to reduce JavaScript sent to the client:
// app/dashboard/page.tsx (Server Component by default)
import { prisma } from '@/lib/prisma';
import { ClientChart } from './client-chart';
// This runs on the server - no JavaScript sent to client!
export default async function DashboardPage() {
// Direct database access in Server Component
const stats = await prisma.stats.aggregate({
_sum: { revenue: true },
_avg: { orderValue: true },
_count: true,
});
return (
<div>
<h1>Dashboard</h1>
{/* Render statistics on server */}
<div className="stats-grid">
<StatCard title="Revenue" value={`$${stats._sum.revenue}`} />
<StatCard title="Avg Order" value={`$${stats._avg.orderValue}`} />
</div>
{/* Only interactive chart needs client-side JS */}
<ClientChart data={stats} />
</div>
);
}
Monitoring and Observability
You can't scale what you can't measure. Implement comprehensive monitoring:
1. Performance Monitoring
// lib/monitoring.ts
import * as Sentry from '@sentry/nextjs';
export function trackPagePerformance() {
// Core Web Vitals
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(console.log); // Cumulative Layout Shift
getFID(console.log); // First Input Delay
getFCP(console.log); // First Contentful Paint
getLCP(console.log); // Largest Contentful Paint
getTTFB(console.log); // Time to First Byte
});
}
// Track API performance
export async function trackAPICall<T>(
name: string,
fn: () => Promise<T>
): Promise<T> {
const start = performance.now();
try {
const result = await fn();
const duration = performance.now() - start;
// Log to monitoring service
await logMetric({
metric: 'api.duration',
value: duration,
tags: { endpoint: name, status: 'success' },
});
return result;
} catch (error) {
const duration = performance.now() - start;
Sentry.captureException(error, {
tags: { endpoint: name },
extra: { duration },
});
throw error;
}
}
2. Key Metrics to Monitor
Application Metrics:
- Response time (p50, p95, p99)
- Error rate by endpoint
- Request rate (requests/second)
- Success rate (2xx/total)
Infrastructure Metrics:
- CPU usage
- Memory usage
- Database connection pool
- Cache hit rate
Business Metrics:
- Active users
- Conversion funnel
- Revenue per user
- Feature adoption
Deployment and Infrastructure
1. Vercel Deployment (Recommended)
Vercel provides the easiest path to production for Next.js:
# Install Vercel CLI
npm i -g vercel
# Deploy to production
vercel --prod
# Environment variables
vercel env add DATABASE_URL production
vercel env add REDIS_URL production
Vercel Benefits:
- Zero-config deployments
- Automatic HTTPS
- Global CDN
- Edge functions
- Preview deployments for PRs
- Built-in analytics
2. Self-Hosted with Docker
For more control, containerize your application:
# Dockerfile
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# Rebuild source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Production image
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
CMD ["node", "server.js"]
Real-World Example: Scaling an E-Commerce Platform
Let me share a real example from a recent project where we scaled an e-commerce platform from 1K to 100K daily active users.
Initial State:
- Single Vercel deployment
- PostgreSQL database on Heroku
- No caching layer
- Average response time: 800ms
- Crashed during Black Friday sale
After Optimization:
- Cloudflare CDN for static assets
- Redis caching layer
- Database read replicas
- Image optimization
- Code splitting
- Response time: 120ms average
- Handled 50K concurrent users during peak
Key Changes:
- Implemented Redis caching (80% cache hit rate)
- Added database indexes on hot queries
- Optimized images saving 60% bandwidth
- Code split reduced initial bundle by 40%
- Used ISR for product pages
- Implemented rate limiting
- Added comprehensive monitoring
Results:
- 85% reduction in response time
- 99.9% uptime during peak traffic
- 40% reduction in hosting costs
- Zero crashes during sale events
Common Pitfalls to Avoid
1. Premature Optimization
Don't optimize before you have data showing it's needed. Start with good architecture and optimize based on real metrics.
2. Ignoring Database Indexes
Every query that powers your application should have appropriate indexes. Use database query analyzers to identify slow queries.
3. Over-Engineering
Start simple. Add complexity only when needed. A monolith that works is better than a microservice architecture that doesn't.
4. No Monitoring
You can't improve what you don't measure. Implement monitoring from day one.
5. Forgetting Error Handling
Errors happen. Handle them gracefully and log them for debugging.
Conclusion
Building scalable applications with Next.js and TypeScript requires thoughtful architecture, proper tooling, and continuous monitoring. The key principles are:
- Design for horizontal scaling from the start
- Implement multi-layer caching aggressively
- Optimize database queries and add proper indexes
- Monitor everything to make data-driven decisions
- Start simple and add complexity as needed
At Yetti LLC, these principles have helped us build applications serving millions of users with excellent performance and reliability.
Ready to build your scalable application? Contact us to discuss your project.
Ready to build your next product?
Let's discuss how we can help bring your vision to life.