Back to all articles
TechnicalFeature

Why Your Next.js App Feels Broken (And How Tests Save Your Refactor)

A practical look at Next.js caching strategies and why high level tests are your safety net when paying down tech debt

Why Your Next.js App Feels Broken (And How Tests Save Your Refactor)

Why Your Next.js App Feels Broken (And How Tests Save Your Refactor)

I sat down last week to figure out what kind of architecture I actually want for every feature in our app.

You know what matters most when you are building something? Consistency. Not cleverness. Not the latest pattern you read about. Just doing things the same way everywhere.

So I started reviewing our code. Five different features. Five different implementations. And I started noticing things.

Some features cleared the cache one way. Some did it another way. Some did not do it at all. One feature had this random function that bypassed our whole system.

Thas the problem. Inconsistency is not just messy. It hides bugs. Real bugs that affect real users.

I want to share what I found and how tests helped me fix it without breaking everything else.

The Hidden Problem With Caching

Next.js caches your data. This makes pages fast. Servers stay cheap. Users are happy.

Then something breaks.

A user visits their billing page. Next.js remembers what it fetched and serves it again. Fast.

The user clicks cancel. The server does its job. Stripe processes the request. The database updates.

But the screen still shows an active subscription.

The cache is holding onto old information. The user refreshes. Still wrong. They log out and back in. Maybe it helps. Maybe not.

They write to support asking why they are still being charged.

They are not being charged. The screen is just showing them yesterday's truth.

Three Ways to Handle Cache

There are three approaches. Each has its place.

Clear the whole path. After something changes, tell Next.js to forget everything about that page.

revalidatePath("/dashboard/settings")

Simple. But heavy. You throw away good data along with the bad.

Clear by tag. Give your cached data names. When something changes, clear only what needs clearing.

// When fetching
const subscription = await fetch('/api/subscription', {
  next: { tags: [`subscription-${userId}`] }
})

// When something changes
revalidateTag(`subscription-${userId}`)

More precise. One user's change does not affect everyone else.

Let time handle it. Some data can be a little stale. Usage numbers do not need to be perfect. Check every minute instead of every request.

const usage = await fetch('/api/usage', {
  next: { revalidate: 60 }
})

The skill is knowing which approach fits where. Billing needs to be right immediately. Usage can wait a minute. Your pricing page can wait an hour.

What I Found in Our Code

Five features. Five different patterns. Some cleared paths. Some cleared nothing at all. One bypassed our entire system with a raw function.

This is tech debt.

Not the loud kind that crashes your app. The quiet kind. Works most of the time. Fails in ways you cannot easily repeat.

Fixing it meant moving files. Changing how things connect. Adding the right clearing calls in the right places.

Big changes. Easy to break something.

Why Tests Matter Here

When you refactor, you change how code works inside. But you want the outside to stay the same.

The billing page should still show subscriptions. The cancel button should still cancel. The invoice list should still show invoices.

Unit tests check small pieces. They tell you if a function works. They do not tell you if the whole feature works.

High level tests check what users actually do. Load the page. See the data. Click the button. See the result.

I moved files around during the refactor. The high level tests caught two mistakes. A typo in an import. A missing call to clear the cache.

Without those tests, both bugs would have reached users.

A Simple Structure

Here is what we settled on:

billing/
├── index.ts           # What we share with the world
├── types.ts           # Shape of our data
├── schemas.ts         # Validation rules
├── billing-cache.ts   # Cache helpers
├── billing-actions.ts # Server actions
└── hooks.ts           # React hooks

The cache file is new. It holds simple helpers:

export function getSubscriptionTag(userId: string) {
  return `billing:subscription:${userId}`
}

export function revalidateBillingCache(userId: string) {
  revalidateTag(getSubscriptionTag(userId))
  revalidateTag(getInvoicesTag(userId))
}

Every action that changes data calls the right helper. No more wondering if the cache is current.

Do the Urgent Work First

When you have many things to fix, start with what is broken.

Our billing had no cache clearing. Users saw wrong data. That came first.

Account settings had clearing but used the heavy approach. It worked. Could be better. Lower priority.

The resume feature had messy code that still worked for users. It could wait.

Fix what is broken. Then improve what works. Then clean what is ugly.

The Simple Truth

Caching makes things fast. But it needs attention. You have to think about what changes and when the old data should go away.

And when you go to fix these things, tests are not extra. They are essential. They stand between you and broken code reaching users.

Unit tests check your pieces. High level tests check your whole.

Before you refactor, make sure your tests cover what matters to users. Then work with confidence. If something breaks, the tests will tell you before anyone else does.

Five features had problems. The refactor fixed them. No new bugs shipped.

That only happened because tests were watching.

Write the tests first. Then fix the code.