
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:
-
Audit: Use the IMPACT framework to assess your current tech debt.
-
Prioritize: Focus on high-impact, high-contagion issues first.
-
Plan: Create a 90-day “Tech Debt Takedown” roadmap.
-
Execute: Implement changes incrementally, measuring impact as you go.
-
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! 💪🚀