Next.js 15 with the App Router is a genuinely great framework — but it has sharp edges that only show up in production. After shipping 8 projects on it, here's what we know.
1. Cache invalidation is still the hardest problem
The App Router's caching is powerful but opaque. revalidatePath doesn't do what most developers think — it invalidates the full route cache, not just the data cache. We've had multiple instances of stale UI persisting after data mutations because of incorrect cache assumptions.
Rule: be explicit about every cache boundary. Use no-store by default on fetch calls that involve user-specific data. Add cache tags to everything you might want to invalidate selectively.
2. Server components don't eliminate client bundle bloat
One of the App Router's promises is smaller client bundles. This is true — but only if you're disciplined about the client/server boundary. A single "use client" directive on a parent component pulls all its children into the client bundle. We use a pattern of wrapping interactive leaves in client components rather than marking entire sections.
3. Streaming + Suspense is production-ready
We were cautious about loading.tsx and Suspense boundaries in the first few months. Now we use them everywhere. The TTFB improvements on data-heavy pages are significant — users see layout immediately while data loads, which feels dramatically faster even if the total load time is similar.
4. Parallel routes are underused
Parallel routes (@slot convention) are one of the most powerful App Router features and almost nobody uses them. We use them for dashboard layouts where multiple independent data sections should load in parallel without a shared loading state.
5. Edge runtime has real limitations
Edge functions are fast, but the limited runtime means no native Node.js modules. We've hit this with PDF generation, image processing, and certain database drivers. Know your runtime requirements before going edge.
6. Type safety across the server/client boundary
Props passed from server to client components must be serialisable. We've added a lint rule to catch non-serialisable types (like Date objects, class instances) being passed across the boundary. Use plain objects and ISO strings.