Skip to content
LEWIS C. LIN AMAZON.COM BESTSELLING AUTHOR
Go back

The Ultimate Guide to Next.js System Design Debt: A Developer's Taxonomy

Edit page

Hey there, Next.js architects! Is your system design feeling more tangled than a box of Christmas lights? Don’t sweat it - I’ve got your back.

Today, we’re diving deep into the 5 most dangerous types of system design debt in Next.js. By the end of this guide, you’ll be armed with the knowledge to turn your spaghetti architecture into a lean, mean, scaling machine.

Let’s break down each type using my battle-tested IMPACT framework:

• Issue: What’s the problem?
• Metrics: How bad is it? (Scale: 1-5)
• Prevention: How to avoid it
• Action: What to do if you’re already in trouble
• Code: Before and after examples
• Tips: Quick wins for immediate improvement

Ready to level up your Next.js architecture? Let’s demolish some system design debt!

1. Monolithic Architecture Overload 🏢

Issue:
Your Next.js app has grown faster than a teenager in a growth spurt. Now you’ve got a monolith that’s harder to maintain than a vintage car collection.

Metrics:
• Impact: 5/5
• Fix Cost: 5/5
• Contagion: 3/5

Prevention:
• Plan for modularity from day one
• Use domain-driven design principles
• Implement clear boundaries between features

Action:
• Identify clear domain boundaries
• Extract features into separate services or serverless functions
• Implement an API gateway for unified access

Code Example:

Before (Monolithic Nightmare):

// pages/api/everything.js
export default async function handler(req, res) {
  switch (req.method) {
    case 'GET':
      if (req.query.type === 'user') {
        // User logic
      } else if (req.query.type === 'product') {
        // Product logic
      } else if (req.query.type === 'order') {
        // Order logic
      }
      break;
    case 'POST':
      if (req.body.action === 'create-user') {
        // User creation logic
      } else if (req.body.action === 'place-order') {
        // Order placement logic
      }
      break;
    // ... more cases
  }
  res.status(200).json({ result: 'Done' });
}

After (Modular Microservices):

// pages/api/users/[id].js
export default async function handler(req, res) {
  const { id } = req.query;
  const user = await fetchUserFromMicroservice(id);
  res.status(200).json(user);
}

// pages/api/products/[id].js
export default async function handler(req, res) {
  const { id } = req.query;
  const product = await fetchProductFromMicroservice(id);
  res.status(200).json(product);
}

// pages/api/orders/create.js
export default async function handler(req, res) {
  const order = await createOrderInMicroservice(req.body);
  res.status(201).json(order);
}

Tips:
• Use Next.js API routes as a lightweight API gateway
• Implement circuit breakers for microservice communication
• Consider using GraphQL for flexible data fetching across services

2. Data Fetching Anti-patterns 🕸️

Issue:
Your data fetching strategy is more tangled than a spider’s web after a hurricane. Client-side fetching, server-side props, and static generation all mixed up like a smoothie gone wrong.

Metrics:
• Impact: 4/5
• Fix Cost: 3/5
• Contagion: 4/5

Prevention:
• Define clear data fetching strategies for different types of data
• Leverage Next.js built-in data fetching methods
• Implement proper caching strategies

Action:
• Audit your current data fetching patterns
• Refactor to use appropriate Next.js data fetching methods
• Implement a caching layer for frequently accessed data

Code Example:

Before (Data Fetching Chaos):

// pages/products/[id].js
import { useState, useEffect } from 'react';

export default function Product({ id }) {
  const [product, setProduct] = useState(null);
  const [relatedProducts, setRelatedProducts] = useState([]);

  useEffect(() => {
    fetch(`/api/products/${id}`)
      .then(res => res.json())
      .then(setProduct);
    
    fetch(`/api/products/${id}/related`)
      .then(res => res.json())
      .then(setRelatedProducts);
  }, [id]);

  if (!product) return <div>Loading...</div>;

  return (
    <div>
      <h1>{product.name}</h1>
      <RelatedProducts products={relatedProducts} />
    </div>
  );
}

After (Optimized Data Fetching):

// app/products/[id]/page.js
import { Suspense } from 'react';
import { getProduct } from '@/lib/products';
import RelatedProducts from './RelatedProducts';

export default async function Product({ params }) {
  const product = await getProduct(params.id);

  return (
    <div>
      <h1>{product.name}</h1>
      <Suspense fallback={<div>Loading related products...</div>}>
        <RelatedProducts productId={params.id} />
      </Suspense>
    </div>
  );
}

// app/products/[id]/RelatedProducts.js
import { getRelatedProducts } from '@/lib/products';

export default async function RelatedProducts({ productId }) {
  const relatedProducts = await getRelatedProducts(productId);

  return (
    <ul>
      {relatedProducts.map(product => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

Tips:
• Use React Server Components for data-fetching components
• Implement stale-while-revalidate caching with SWR or React Query
• Consider using Incremental Static Regeneration for semi-dynamic content

3. Inefficient Database Schema Design 🗄️

Issue:
Your database schema is more confusing than a maze designed by M.C. Escher. Denormalized data, missing indexes, and more JOINs than a yoga class.

Metrics:
• Impact: 4/5
• Fix Cost: 4/5
• Contagion: 3/5

Prevention:
• Design schemas with scalability in mind
• Use proper indexing strategies
• Implement database migrations for schema changes

Action:
• Analyze query performance and identify bottlenecks
• Normalize (or denormalize) data appropriately
• Implement proper indexing and caching strategies

Code Example:

Before (Inefficient Schema):

// prisma/schema.prisma
model User {
  id        Int      @id @default(autoincrement())
  name      String
  email     String   @unique
  orders    Order[]
}

model Order {
  id        Int      @id @default(autoincrement())
  userId    Int
  user      User     @relation(fields: [userId], references: [id])
  products  String   // Stored as JSON string
  totalAmount Float
}

// pages/api/user-orders.js
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

export default async function handler(req, res) {
  const { userId } = req.query
  const orders = await prisma.order.findMany({
    where: { userId: parseInt(userId) },
    include: { user: true }
  })
  res.json(orders.map(order => ({
    ...order,
    products: JSON.parse(order.products)
  })))
}

After (Optimized Schema):

// prisma/schema.prisma
model User {
  id        Int      @id @default(autoincrement())
  name      String
  email     String   @unique
  orders    Order[]
}

model Order {
  id        Int      @id @default(autoincrement())
  userId    Int
  user      User     @relation(fields: [userId], references: [id])
  totalAmount Float
  orderProducts OrderProduct[]
}

model Product {
  id        Int      @id @default(autoincrement())
  name      String
  price     Float
  orderProducts OrderProduct[]
}

model OrderProduct {
  id        Int      @id @default(autoincrement())
  orderId   Int
  order     Order    @relation(fields: [orderId], references: [id])
  productId Int
  product   Product  @relation(fields: [productId], references: [id])
  quantity  Int
}

// pages/api/user-orders.js
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

export default async function handler(req, res) {
  const { userId } = req.query
  const orders = await prisma.order.findMany({
    where: { userId: parseInt(userId) },
    include: {
      orderProducts: {
        include: { product: true }
      }
    }
  })
  res.json(orders)
}

Tips:
• Use an ORM like Prisma for type-safe database operations
• Implement database indexing for frequently queried fields
• Consider using database views for complex, frequently-used queries

4. Inadequate Caching Strategy 🐢

Issue:
Your app’s performance is slower than a snail on vacation. Every request hits the database like it’s a piñata at a kid’s birthday party.

Metrics:
• Impact: 3/5
• Fix Cost: 2/5
• Contagion: 3/5

Prevention:
• Implement a multi-layer caching strategy
• Use appropriate caching mechanisms for different data types
• Regularly review and update cache invalidation policies

Action:
• Identify frequently accessed, rarely changing data
• Implement in-memory caching for hot data
• Use CDN caching for static assets and pages

Code Example:

Before (No Caching):

// pages/api/popular-products.js
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

export default async function handler(req, res) {
  const popularProducts = await prisma.product.findMany({
    orderBy: { views: 'desc' },
    take: 10
  })
  res.json(popularProducts)
}

After (Implemented Caching):

// lib/redis.js
import { Redis } from '@upstash/redis'

export const redis = new Redis({
  url: process.env.REDIS_URL,
  token: process.env.REDIS_TOKEN,
})

// pages/api/popular-products.js
import { PrismaClient } from '@prisma/client'
import { redis } from '@/lib/redis'

const prisma = new PrismaClient()

export default async function handler(req, res) {
  const cacheKey = 'popular-products'
  let popularProducts = await redis.get(cacheKey)

  if (!popularProducts) {
    popularProducts = await prisma.product.findMany({
      orderBy: { views: 'desc' },
      take: 10
    })
    await redis.set(cacheKey, JSON.stringify(popularProducts), 'EX', 3600) // Cache for 1 hour
  } else {
    popularProducts = JSON.parse(popularProducts)
  }

  res.json(popularProducts)
}

Tips:
• Use Redis for fast, in-memory caching
• Implement stale-while-revalidate caching patterns
• Don’t forget to implement proper cache invalidation strategies

5. Inappropriate Data Storage Choices 📦

Issue:
You’re trying to fit square data into round database holes. Your relational data is in MongoDB, and your document data is squeezed into MySQL tables.

Metrics:
• Impact: 4/5
• Fix Cost: 5/5
• Contagion: 2/5

Prevention:
• Analyze data models and access patterns before choosing a database
• Consider polyglot persistence for complex applications
• Plan for data migration and sync strategies

Action:
• Audit your current data models and access patterns
• Identify mismatches between data and storage solutions
• Plan and execute a phased migration to appropriate databases

Code Example:

Before (Mismatched Data Storage):

// Using MongoDB for relational data
// models/User.js
import mongoose from 'mongoose'

const UserSchema = new mongoose.Schema({
  name: String,
  email: String,
  orders: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Order' }]
})

export const User = mongoose.model('User', UserSchema)

// pages/api/user-orders.js
import dbConnect from '@/lib/dbConnect'
import { User } from '@/models/User'

export default async function handler(req, res) {
  await dbConnect()
  const { userId } = req.query
  const user = await User.findById(userId).populate('orders')
  res.json(user.orders)
}

After (Appropriate Data Storage):

// Using Prisma with PostgreSQL for relational data
// prisma/schema.prisma
model User {
  id        Int      @id @default(autoincrement())
  name      String
  email     String   @unique
  orders    Order[]
}

model Order {
  id        Int      @id @default(autoincrement())
  userId    Int
  user      User     @relation(fields: [userId], references: [id])
  total     Float
  items     Json
}

// pages/api/user-orders.js
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

export default async function handler(req, res) {
  const { userId } = req.query
  const orders = await prisma.order.findMany({
    where: { userId: parseInt(userId) }
  })
  res.json(orders)
}

Tips:
• Use relational databases (e.g., PostgreSQL) for structured, relational data
• Consider document databases (e.g., MongoDB) for flexible, schema-less data
• Implement data access layers to abstract database interactions

Remember, Next.js warriors: Your system design is the foundation of your app’s success. Take the time to plan, refactor, and optimize. Your future self (and your users) will thank you!

Want more battle-tested strategies for Next.js system design? Check out my “Next.js Architect’s Playbook” course. We’ll turn you from a code monkey into a system design gorilla in no time! 🦍💪

Now go forth and conquer that system design debt! Your scalable, maintainable Next.js app awaits!


Edit page
Share this post on:

Previous Post
Decode and Conquer Sketchnote: Frameworks You Need to Know Before Your Next PM Interview
Next Post
Taxonomy of Next.js Tech Debt