Development

Building Scalable Web Applications with Next.js and TypeScript

DevelopmentOctober 17, 202515 min read
Developer working on scalable web application architecture

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:

  1. Implemented Redis caching (80% cache hit rate)
  2. Added database indexes on hot queries
  3. Optimized images saving 60% bandwidth
  4. Code split reduced initial bundle by 40%
  5. Used ISR for product pages
  6. Implemented rate limiting
  7. 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:

  1. Design for horizontal scaling from the start
  2. Implement multi-layer caching aggressively
  3. Optimize database queries and add proper indexes
  4. Monitor everything to make data-driven decisions
  5. 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.

#Next.js#TypeScript#Scalability#Performance#Architecture
Get Started

Ready to build your next product?

Let's discuss how we can help bring your vision to life.