DEV Community

JSGuruJobs
JSGuruJobs

Posted on

I Audited 9 Next.js Production Apps. Every Single One Had the Same 5 Problems

Over the past three months I reviewed nine Next.js App Router applications in production. Different teams, different industries, different company sizes. E-commerce, SaaS dashboards, content platforms, internal tools.

Every single one had the same five architectural problems. Not bugs. Not missing features. Architectural decisions that silently destroyed performance, inflated hosting costs, and created stale data nightmares that took weeks to debug.

Here is what I found and how to fix each one in under five minutes.

Problem 1. "use client" at the page level

Eight out of nine apps had entire page components marked as Client Components. The reason was always the same. One interactive element somewhere on the page needed useState or onClick. So the developer added "use client" to the top of the page file and moved on.

The consequence: the entire page ships as JavaScript. Server rendering benefits disappear. Bundle size doubles or triples. Hydration takes longer. The user sees a blank page until all that JavaScript downloads and executes.

The fix:

Extract the interactive piece into its own component. Keep the page as a Server Component.

// page.tsx (Server Component, no "use client")
export default async function ProductPage({ params }) {
  const product = await getProduct(params.id)

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <ProductImages images={product.images} />
      <AddToCartButton productId={product.id} />
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode
// AddToCartButton.tsx
"use client"

export function AddToCartButton({ productId }: { productId: string }) {
  const [loading, setLoading] = useState(false)
  // only this tiny component ships JS to the browser
}
Enter fullscreen mode Exit fullscreen mode

Rule of thumb: "use client" should only appear on leaf components. Never on pages. Never on layouts.

Problem 2. Sequential data fetching

Seven out of nine apps fetched data sequentially in Server Components without realizing it.

// This takes 800ms (200 + 200 + 200 + 200)
const product = await getProduct(id)
const reviews = await getReviews(id)
const related = await getRelatedProducts(id)
const inventory = await checkInventory(id)
Enter fullscreen mode Exit fullscreen mode

Each await blocks the next one. Four independent data sources, each taking 200ms, means the page takes 800ms minimum.

The fix:

// This takes 200ms (all four in parallel)
const [product, reviews, related, inventory] = await Promise.all([
  getProduct(id),
  getReviews(id),
  getRelatedProducts(id),
  checkInventory(id),
])
Enter fullscreen mode Exit fullscreen mode

Same data. Same page. 75 percent faster. This single change was the biggest performance win in every app I reviewed.

For data sources with different speeds, combine Promise.all with Suspense streaming:

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

  return (
    <div>
      <ProductDetails product={product} />
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews productId={params.id} />
      </Suspense>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Product details appear instantly. Reviews stream in when ready. Users see content immediately instead of waiting for the slowest query.

Problem 3. Caching panic

Five out of nine apps had cache: "no-store" on every single fetch call. When I asked why, the answer was always: "We had stale data in production once, so we disabled caching everywhere."

The result: every page hits the server on every request. No CDN caching. No ISR. Hosting costs 10x to 50x higher than necessary. Every user waits for the server even on pages that change once a day.

The fix:

Decide per route, not globally.

// Static page (marketing, docs, about)
// Default behavior, no config needed. Cached at build time.

// ISR page (products, blog posts, profiles)
export const revalidate = 60  // regenerate every 60 seconds

// Dynamic page (dashboard, cart, authenticated content)
export const dynamic = "force-dynamic"
Enter fullscreen mode Exit fullscreen mode

Set the caching strategy at the page level using route segment config. Not on individual fetch calls scattered across dozens of files. This makes the caching intent explicit and reviewable.

If you need one data source fresh on an otherwise static page, use noStore() from next/cache on that specific call, not on the entire route.

Problem 4. API routes for everything

Six out of nine apps had dozens of API route files for simple mutations. /api/add-to-cart, /api/update-profile, /api/toggle-favorite, /api/submit-feedback. Each one a separate file with request parsing, validation, response formatting, and error handling boilerplate.

In the App Router, Server Actions replace most of these.

Before (API route):

// app/api/add-to-cart/route.ts
export async function POST(request: Request) {
  const session = await getSession()
  if (!session) return Response.json({ error: "Unauthorized" }, { status: 401 })

  const body = await request.json()
  await db.cart.add({ userId: session.userId, productId: body.productId })

  return Response.json({ success: true })
}
Enter fullscreen mode Exit fullscreen mode
// Client component calling it
const res = await fetch("/api/add-to-cart", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ productId }),
})
Enter fullscreen mode Exit fullscreen mode

After (Server Action):

// actions/cart.ts
"use server"

export async function addToCart(productId: string) {
  const session = await getSession()
  if (!session) throw new Error("Unauthorized")

  await db.cart.add({ userId: session.userId, productId })
  revalidatePath("/cart")
}
Enter fullscreen mode Exit fullscreen mode
// Client component calling it
import { addToCart } from "@/actions/cart"

<button onClick={() => addToCart(product.id)}>Add to Cart</button>
Enter fullscreen mode Exit fullscreen mode

Less code. Type safe across server and client. Automatic cache revalidation. No manual fetch configuration.

Keep API routes for webhooks, external integrations, and endpoints that third party services need to call. For internal mutations triggered by your own UI, Server Actions are almost always the better choice.

Problem 5. No loading.js or error.js anywhere

Seven out of nine apps had zero loading.js files and zero error.js files. Users saw blank white pages during data fetching. Unhandled errors crashed the entire application instead of showing a graceful fallback.

The fix takes 60 seconds per route:

// app/dashboard/loading.tsx
export default function Loading() {
  return <DashboardSkeleton />
}
Enter fullscreen mode Exit fullscreen mode
// app/dashboard/error.tsx
"use client"

export default function Error({ error, reset }) {
  return (
    <div>
      <h2>Something went wrong</h2>
      <button onClick={() => reset()}>Try again</button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

The loading.js file automatically wraps the page in a Suspense boundary. The error.js file automatically wraps it in an error boundary. The rest of the application keeps working even if this route fails.

Add loading.js to every route with async data fetching. Add error.js to every route group. This takes 20 minutes for an entire application and eliminates the two most common UX failures I see in production Next.js apps.

Quick audit checklist

Run these on your codebase right now.

Search for "use client" in page.tsx files. Every hit is a candidate for extraction.

Search for sequential awaits in Server Components. Any function with two or more awaits on independent data sources should use Promise.all.

Search for cache: "no-store" and count the results. If every fetch has it, your caching strategy is "no strategy."

Count your API route files. For each one, ask: is this called by my own UI or by an external service? If internal, consider a Server Action.

Count your loading.js and error.js files. If the number is zero, your users are seeing blank pages and crashes.

Five problems. Five fixes. None of them take more than an afternoon. All of them have an immediate, measurable impact on performance, reliability, and hosting costs.

The framework gives you the tools. Using them correctly is the job.

Building production Next.js apps and want more practical architecture guides? I write about real patterns from real codebases at jsgurujobs.com.

Top comments (0)