Skip to content
Back to Software Dev
Software Dev4 min read

Next.js App Router Gotchas

nextjsreactfrontend
Share

I shipped a full product on Next.js 15 App Router. It's good. It's also full of behavior that will surprise you if you come from Pages Router. Here's what tripped me up.

the caching defaults changed (again)

Next.js 15 finally made fetch requests uncached by default. In Next.js 14, everything was cached aggressively — fetch in server components, route handlers, even GET API routes. You had to opt out of caching. Now you opt in.

This sounds minor until you realize how many bugs it caused in 14. I had a dashboard showing stale assessment data because a server component's fetch was cached and I didn't know it. revalidatePath fixed it, but only after I spent an hour wondering why the data wasn't updating.

In 15, the default is sane. But you should still be explicit:

// Explicit is better than implicit
const data = await fetch(url, {
  next: { revalidate: 60 }, // revalidate every 60 seconds
});
 
// Or for truly static data
const data = await fetch(url, {
  cache: "force-cache",
});

loading.tsx is a sharp edge

loading.tsx creates an automatic Suspense boundary for the nearest page.tsx. Sounds great. The gotcha: it wraps the entire page, not individual data-fetching components.

If your page has three data sources and one is slow, the entire page shows the loading state. Not just the slow part.

The fix is manual Suspense boundaries:

export default function Dashboard() {
  return (
    <div>
      <Suspense fallback={<AssessmentsSkeleton />}>
        <AssessmentsList />
      </Suspense>
      <Suspense fallback={<StatsSkeleton />}>
        <StatsPanel />
      </Suspense>
      <Suspense fallback={<ActivitySkeleton />}>
        <RecentActivity />
      </Suspense>
    </div>
  );
}

Each section loads independently. Fast sections render immediately. Slow sections show their own skeleton. This is better UX but it means loading.tsx is rarely what you actually want for complex pages.

parallel routes are powerful and confusing

Parallel routes let you render multiple pages simultaneously in the same layout using named slots:

app/
  @sidebar/
    page.tsx
  @main/
    page.tsx
  layout.tsx

The layout receives both as props. Great for dashboards. But the mental model gets weird with navigation: each slot navigates independently, and if one slot doesn't have a matching route, Next.js renders the default.tsx — or throws a 404 if you forgot it.

I use parallel routes for the AssessAI admin panel. The sidebar shows the question list; the main area shows the selected question. Works well once you internalize the default.tsx requirement. I just create default.tsx in every parallel route slot immediately to avoid the random 404s.

server actions and route handlers: pick one

App Router gives you two ways to handle mutations: server actions (inline "use server" functions) and route handlers (app/api/*/route.ts). Both work. But mixing them in the same feature creates confusion.

My rule: server actions for simple form submissions. Route handlers for anything called from client-side JavaScript, webhooks, or external services. Never call a route handler from a server action or vice versa.

generateStaticParams and dynamic routes

If you have dynamic routes like [slug] and want static generation, you need generateStaticParams:

export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

The gotcha: if dynamicParams is true (default), visiting a slug not returned by generateStaticParams triggers on-demand rendering. If it's false, you get a 404. I forgot to set this on one route and couldn't figure out why certain pages worked in dev but not in production — they were being generated on-demand in dev but the production build only had the static params.

middleware runs on every request

Middleware in the App Router runs on every request, including static assets, unless you configure matcher. Without it, your auth check runs on every CSS file, every image, every font.

export const config = {
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico).*)",
  ],
};

This is in the docs, but it's easy to miss. And the performance impact is real if you're doing async work in middleware.

the verdict

App Router is the right direction. Server-first rendering, streaming, parallel data fetching — these are real improvements. But the mental model is different enough from Pages Router that you'll spend time re-learning things you thought you knew.

My advice: read the caching docs twice. Add default.tsx to every parallel route. Be explicit about every fetch cache option. And test your production build early — the dev server hides several of these issues.


Share

More in Software Dev