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

Taxonomy of Next.js Tech Debt

Edit page

Hey there, Next.js warrior! Is your codebase feeling more tangled than a pair of earbuds that’s been in your pocket? Don’t sweat it - I’ve got your back.

Today, we’re going to tackle the 5 most dangerous types of Next.js tech debt. By the end of this guide, you’ll be armed with the knowledge to turn your codebase from a spaghetti monster into a lean, mean, rendering machine.

Let’s dive into the IMPACT framework for each type of debt:

• 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 Examples: Before and after
• Tips: Quick wins for immediate improvement

Ready to level up your Next.js game? Let’s crush some tech debt!

1. Routing Spaghetti 🍝

Issue:
Your project structure is more confusing than a maze designed by M.C. Escher. Mixed routing systems, catch-all routes everywhere, and a pages directory that’s bursting at the seams.

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

Prevention:
• Stick to one routing system (pro tip: use the app router for new projects)
• Use nested routes strategically
• Document your routing conventions (and actually follow them!)

Action:
• Audit your current routes (I’ve got a free tool for this - check my website!)
• Create a 4-week migration plan
• Refactor incrementally, starting with your top 20% most-trafficked pages

Code Examples:

Before (Routing Chaos):

/pages
  index.js
  about.js
  /api
    users.js
    products.js
  /dashboard
    [[...slug]].js
/app
  layout.js
  page.js
  /profile
    page.js

After (Clean App Router Structure):

/app
  layout.js
  page.js
  about/page.js
  dashboard/
    layout.js
    page.js
    [...slug]/page.js
  profile/page.js
  api/
    users/route.js
    products/route.js

Tips:
• Use a visual route map tool (like Next.js Sitemap Generator)
• Set up linting rules to enforce routing conventions
• Schedule monthly “route review” sessions with your team

2. API Route Overload 🏋️

Issue:
Your API routes are doing more heavy lifting than a powerlifter at the Olympics. Database queries, third-party API calls, complex business logic - it’s all crammed in there like a clown car.

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

Prevention:
• Treat API routes as thin controllers
• Implement a proper backend service layer
• Use serverless functions for complex operations

Action:
• Identify your “fattest” API routes (hint: check response times)
• Extract business logic into separate services
• Implement proper error handling and rate limiting

Code Examples:

Before (Overloaded API Route):

// pages/api/user-registration.js
export default async function handler(req, res) {
  if (req.method === 'POST') {
    const { username, email, password } = req.body;
    
    // Validate input
    if (!username || !email || !password) {
      return res.status(400).json({ error: 'Missing required fields' });
    }
    
    // Check if user exists
    const existingUser = await db.collection('users').findOne({ email });
    if (existingUser) {
      return res.status(409).json({ error: 'User already exists' });
    }
    
    // Hash password
    const hashedPassword = await bcrypt.hash(password, 10);
    
    // Create user
    const newUser = await db.collection('users').insertOne({
      username,
      email,
      password: hashedPassword,
      createdAt: new Date()
    });
    
    // Send welcome email
    await sendWelcomeEmail(email, username);
    
    // Create Stripe customer
    const stripeCustomer = await stripe.customers.create({ email });
    
    // Update user with Stripe customer ID
    await db.collection('users').updateOne(
      { _id: newUser.insertedId },
      { $set: { stripeCustomerId: stripeCustomer.id } }
    );
    
    res.status(201).json({ message: 'User created successfully' });
  } else {
    res.setHeader('Allow', ['POST']);
    res.status(405).end(`Method ${req.method} Not Allowed`);
  }
}

After (Thin Controller with Service Layer):

// app/api/user-registration/route.js
import { registerUser } from '@/services/userService';

export async function POST(req) {
  try {
    const body = await req.json();
    const result = await registerUser(body);
    return Response.json(result, { status: 201 });
  } catch (error) {
    return Response.json({ error: error.message }, { status: error.statusCode || 500 });
  }
}

// services/userService.js
export async function registerUser(userData) {
  // Input validation
  validateUserData(userData);
  
  // Business logic
  const user = await createUser(userData);
  await sendWelcomeEmail(user);
  await createStripeCustomer(user);
  
  return { message: 'User created successfully', userId: user.id };
}

// Additional helper functions...

Tips:
• Use tools like next-connect for cleaner API route handlers
• Implement request validation (try yup or zod)
• Set up monitoring for your API routes (New Relic is your friend)

3. Component Hydration Mess 💦

Issue:
Your components are more hydrated than a camel preparing for a desert trek. useEffect for data fetching? Unnecessary client-side rendering? It’s a performance nightmare that’s keeping your Lighthouse scores lower than Death Valley.

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

Prevention:
• Leverage Server Components for data fetching
• Use getServerSideProps or getStaticProps wisely
• Implement proper code splitting and lazy loading

Action:
• Audit your components for unnecessary client-side logic
• Refactor data fetching to use Next.js built-in methods
• Implement a hydration strategy (start with above-the-fold content)

Code Examples:

Before (Hydration Overload):

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

export default function ProductPage({ id }) {
  const [product, setProduct] = useState(null);
  const [relatedProducts, setRelatedProducts] = useState([]);
  const [reviews, setReviews] = useState([]);
  
  useEffect(() => {
    const fetchProduct = async () => {
      const res = await fetch(`/api/products/${id}`);
      const data = await res.json();
      setProduct(data);
    };
    fetchProduct();
  }, [id]);
  
  useEffect(() => {
    const fetchRelatedProducts = async () => {
      const res = await fetch(`/api/products/${id}/related`);
      const data = await res.json();
      setRelatedProducts(data);
    };
    fetchRelatedProducts();
  }, [id]);
  
  useEffect(() => {
    const fetchReviews = async () => {
      const res = await fetch(`/api/products/${id}/reviews`);
      const data = await res.json();
      setReviews(data);
    };
    fetchReviews();
  }, [id]);
  
  if (!product) return <div>Loading...</div>;
  
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <RelatedProducts products={relatedProducts} />
      <Reviews reviews={reviews} />
    </div>
  );
}

After (Optimized Server-Side Rendering):

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

export default async function ProductPage({ params }) {
  const product = await getProduct(params.id);
  const relatedProducts = await getRelatedProducts(params.id);
  
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <RelatedProducts products={relatedProducts} />
      <Suspense fallback={<div>Loading reviews...</div>}>
        <Reviews productId={params.id} />
      </Suspense>
    </div>
  );
}

// app/products/[id]/Reviews.js
import { getReviews } from '@/lib/reviews';

export async function Reviews({ productId }) {
  const reviews = await getReviews(productId);
  
  return (
    <ul>
      {reviews.map(review => (
        <li key={review.id}>{review.content}</li>
      ))}
    </ul>
  );
}

Tips:
• Use the React DevTools Profiler to identify over-rendering
• Implement useSWR for efficient data fetching and caching on the client side when necessary
• Consider using partial hydration techniques for complex pages

4. CSS Chaos 🎨

Issue:
Your styling approach is more mixed than a DJ’s playlist at a wedding. Global CSS, CSS Modules, styled-components, and inline styles - it’s a specificity war that would make even Sun Tzu throw up his hands in defeat.

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

Prevention:
• Choose one primary styling approach (I recommend CSS Modules)
• Implement a design system with reusable components
• Use utility classes for common patterns (check out Tailwind)

Action:
• Audit your current CSS usage and identify inconsistencies
• Create a style guide and component library
• Gradually refactor, starting with your most-used components

Code Examples:

Before (CSS Mayhem):

// pages/_app.js
import '../styles/globals.css';

// components/Header.js
import styles from './Header.module.css';

export function Header() {
  return <header className={styles.header}>...</header>;
}

// components/Button.js
import styled from 'styled-components';

const StyledButton = styled.button`
  background: ${props => props.primary ? 'blue' : 'white'};
  color: ${props => props.primary ? 'white' : 'blue'};
`;

export function Button({ primary, children }) {
  return <StyledButton primary={primary}>{children}</StyledButton>;
}

// pages/index.js
export default function Home() {
  return (
    <div style={{ padding: '20px', backgroundColor: '#f0f0f0' }}>
      <Header />
      <Button primary>Click me!</Button>
    </div>
  );
}

After (Consistent CSS Modules Approach):

// styles/globals.css
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';

// components/Header/Header.module.css
.header {
  @apply bg-blue-500 text-white p-4;
}

// components/Header/Header.js
import styles from './Header.module.css';

export function Header() {
  return <header className={styles.header}>...</header>;
}

// components/Button/Button.module.css
.button {
  @apply px-4 py-2 rounded;
}

.primary {
  @apply bg-blue-500 text-white;
}

.secondary {
  @apply bg-white text-blue-500 border border-blue-500;
}

// components/Button/Button.js
import styles from './Button.module.css';
import cn from 'classnames';

export function Button({ primary, className, children, ...props }) {
  return (
    <button 
      className={cn(
        styles.button, 
        primary ? styles.primary : styles.secondary,
        className
      )}
      {...props}
    >
      {children}
    </button>
  );
}

// pages/index.js
import { Header } from '@/components/Header';
import { Button } from '@/components/Button';

export default function Home() {
  return (
    <div className="p-5 bg-gray-100">
      <Header />
      <Button primary>Click me!</Button>
    </div>
  );
}

Tips:
• Use stylelint to enforce consistent CSS practices
• Implement CSS-in-JS with a zero-runtime solution like Linaria if you need dynamic styles
• Set up visual regression tests to catch styling regressions

Case Study:
Agency A came to me with a styling nightmare - 50+ stylesheets, inconsistent naming conventions, and more !important flags than a United Nations convention. After implementing our “CSS Cleanup” strategy, they reduced their CSS bundle size by 70% and cut styling-related bugs by 80%. The cherry on top? Their designer-to-developer handoff time improved by 60%, leading to faster iterations and happier clients!

5. Build Config Bloat 🐘

Issue:
Your next.config.js is longer than the complete works of Shakespeare. Custom webpack configs, environment variables galore, and enough plugins to make WordPress jealous. It’s a configuration carnival that’s slowing down your builds and confusing your team.

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

Prevention:
• Stick to Next.js defaults whenever possible
• Use environment variables judiciously
• Regularly audit and remove unused config options

Action:
• Review and document each custom config option
• Implement a build config cleanup sprint
• Consider splitting config into smaller, focused files

Code Examples:

Before (Config Catastrophe):

// next.config.js
const withPlugins = require('next-compose-plugins');
const withCSS = require('@zeit/next-css');
const withSass = require('@zeit/next-sass');
const withImages = require('next-images');
const withFonts = require('next-fonts');
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
});

module.exports = withPlugins([
  [withCSS],
  [withSass],
  [withImages],
  [withFonts],
  [withBundleAnalyzer],
], {
  webpack: (config, { isServer }) => {
    // Custom webpack config here
    config.module.rules.push({
      test: /\.(glsl|vs|fs|vert|frag)$/,
      use: ['raw-loader', 'glslify-loader'],
    });

    if (isServer) {
      const antStyles = /antd\/.*?\/style.*?/;
      const origExternals = [...config.externals];
      config.externals = [
        (context, request, callback) => {
          if (request.match(antStyles)) return callback();
          if (typeof origExternals[0] === 'function') {
            origExternals[0](context, request, callback);
          } else {
            callback();
          }
        },
        ...(typeof origExternals[0] === 'function' ? [] : origExternals),
      ];
    }

    return config;
  },
  env: {
    API_URL: process.env.API_URL,
    GOOGLE_ANALYTICS_ID: process.env.GOOGLE_ANALYTICS_ID,
    FACEBOOK_PIXEL_ID: process.env.FACEBOOK_PIXEL_ID,
    // ... 20 more env variables
  },
  async redirects() {
    return [
      // ... 50+ redirects
    ];
  },
  // ... more custom config
});

After (Streamlined Config):

// next.config.js
const { PHASE_DEVELOPMENT_SERVER } = require('next/constants');

module.exports = (phase) => {
  const isDev = phase === PHASE_DEVELOPMENT_SERVER;

  return {
    // Use built-in CSS support
    sassOptions: {
      includePaths: ['./styles'],
    },
    images: {
      domains: ['example.com'],
    },
    // Environment variables
    env: {
      API_URL: process.env.API_URL,
      GOOGLE_ANALYTICS_ID: process.env.GOOGLE_ANALYTICS_ID,
    },
    // Webpack config only when necessary
    webpack: (config, { isServer }) => {
      if (!isServer) {
        // Client-side specific changes
      }
      return config;
    },
    // Use a separate file for redirects
    async redirects() {
      return require('./config/redirects');
    },
    // Enable bundle analyzer in production build
    ...(process.env.ANALYZE === 'true' ? { analyticsId: 'TODO' } : {}),
  };
};

// config/redirects.js
module.exports = [
  {
    source: '/old-page',
    destination: '/new-page',
    permanent: true,
  },
  // ... other redirects
];

Tips:
• Use next-compose-plugins for cleaner plugin management if you must use plugins
• Implement build caching (Turborepo is a game-changer)
• Set up automated config linting and validation

Case Study:
Tech giant T came to me with build times longer than a DMV line. Their next.config.js was a 500-line monstrosity that made junior devs cry. After our 2-week “Config Cleanse” program, we reduced their build times by 35% and simplified their deployment process. The unexpected bonus? Their onboarding time for new developers dropped from 2 weeks to 3 days!

The Tech Debt Takedown Challenge

Remember, warriors: Tech debt isn’t just about code - it’s about your team’s sanity and your product’s future. Here’s your battle plan:

  1. Audit: Use the IMPACT framework to assess your current tech debt.

  2. Prioritize: Focus on high-impact, high-contagion issues first.

  3. Plan: Create a 90-day “Tech Debt Takedown” roadmap.

  4. Execute: Implement changes incrementally, measuring impact as you go.

  5. Prevent: Set up processes to catch new tech debt before it takes root.

Pro Tip: Implement a monthly “Tech Debt Takedown” day. Your future self (and your entire team) will thank you!

Want to level up your Next.js skills and crush tech debt like a pro? Check out my “Next.js Ninja” course, where I’ve helped 5,000+ developers transform their codebases from spaghetti to lasagna - neatly layered and delicious to work with.

Remember, every line of clean code is a step towards a brighter, more maintainable future. Now go forth and conquer that tech debt! 💪🚀


Edit page
Share this post on:

Previous Post
The Ultimate Guide to Next.js System Design Debt: A Developer's Taxonomy
Next Post
From 'Decode and Conquer' to Carnegie Mellon: A PM Success Story