This site, duskolicanin.com, runs on Next.js 16 with the App Router, and I still write Server Actions the way I learned them on Next.js 15, the version most SaaS teams actually run in production right now. App Router adoption outran the docs for a while. The gap between "the tutorial worked" and "this survives a real SaaS dashboard under load" is where most of the pain lives, and it shows up in the same three places on every project: which mutation mechanism to reach for, what gets cached and when, and how a dashboard layout stays fast once it has more than one panel fetching data. This is that gap, written down once so I stop re-deriving it on the next build.
Key Takeaways

- Server Actions handle anything a human triggers from your UI. Route Handlers handle anything a machine triggers: webhooks, third-party callers, mobile clients.
- Next.js 15 shipped
"use cache"as an experimental flag. Next.js 16 made it the stable default caching model, and that's a bigger shift than most migration guides let on. - Parallel routes (
@folderslots) give each piece of a dashboard its own loading and error boundary, so one slow chart doesn't freeze the whole page. - Route Handlers still own webhooks and any endpoint a non-browser client calls. Server Actions were never meant to replace that job.
Server Actions or Route Handlers? The App Router Rule I Actually Use

If a human clicks a button in your UI, reach for a Server Action; if a machine calls the endpoint unprompted, write a Route Handler.
I didn't invent that split. Atlas Whoff wrote it up on dev.to after spending three months defaulting every mutation to a Route Handler, porting Express habits into a framework that didn't need them. Manual fetch() calls stacked up. router.refresh() got forgotten in three different places. Three months of that before he rewrote roughly half the app around Server Actions and cut close to 400 lines of plumbing that existed purely to call his own backend. That number stuck with me because it's the kind of waste that never shows up in a code review, it just accumulates quietly until someone finally counts it.
Route Handlers aren't wrong, to be clear. They were the only tool for years, so reaching for one first is a completely reasonable default that just happens to be outdated now. The mistake is never revisiting the default once a better-suited primitive exists for half the cases you're using it for.
MakerKit's SaaS starter kits draw the same line in production: dozens of Server Actions cover team management, profile updates, and feature toggles, while Route Handlers are reserved for the calls that don't originate from your own React tree.
| Trigger | Use | Why | |---|---|---| | A form submit or button click in your app | Server Action | Runs colocated with the component, gets built-in loading states, no fetch boilerplate | | A Stripe or Resend webhook | Route Handler | The caller isn't your React app and can't invoke a Server Action | | A mobile app or third-party integration | Route Handler | Needs a stable HTTP contract, not a framework-internal RPC | | A background job triggering a mutation | Route Handler | No browser session, no form to submit |
The rule glosses over one thing: a Server Action is still a public HTTP endpoint underneath. The framework encrypts the closure and rotates the action ID, but it does nothing about authorization. Validate the input, check that the caller actually owns the row they're mutating, and don't confuse a passing Zod schema with a permission check. I've seen well-formed objects point at data the caller had no business touching, and the schema validator waved it through every time.
What Actually Changed in Next.js's App Router Caching for 2026?

"use cache" moved from an experimental flag in Next.js 15 to Next.js 16's stable default caching model, inverting how caching works by default.
Under the old model, the App Router tried to cache aggressively out of the box, and half of every Next.js support thread was someone asking why a page wasn't updating. Next.js's own version history is blunt about the timeline: "use cache" shipped experimental in 15.0.0, and it wasn't until 16.0.0 that Cache Components made it the stable, opt-in default, with the unstable_ prefixes finally dropped from cacheLife, cacheTag, and updateTag.
Actually, calling it a caching feature undersells it. It's a default reversal: every route, component, and function is dynamic and runs at request time unless you explicitly mark it 'use cache'. Nothing gets cached by accident anymore.
If you're still on 15, this doesn't hit you yet. But you should read the migration path before your 16 upgrade lands on a Friday and someone discovers half the dashboard is suddenly rendering fresh on every request because nothing was ever marked cacheable in the first place. Mark the expensive stuff explicitly instead: a getData() function wrapped in 'use cache' with a cacheTag('products') call, invalidated on write with updateTag('products') from inside the Server Action that changed the row. That pairing, cache the read and tag-invalidate on the write, is most of what you need for a dashboard that stays fast without serving stale numbers to a customer who just changed their plan.
How Do You Structure a Dashboard Layout With Parallel Routes?
Split each independent panel of the dashboard into its own @slot, so a slow-loading chart never blocks the rest of the shell from painting.
Picture the SaaS dashboard you've built a dozen times: a nav rail, a KPI strip, an activity feed, maybe a chart that hits a slower aggregation query. Bolt them all into one page component and the whole thing waits on the slowest fetch. Split them and each slot streams independently.
- Create sibling
@analytics,@activity, and@teamfolders next to your dashboard route, each with its ownpage.tsx. - Add each slot as a named prop to the shared
layout.tsx—{ analytics, activity, team }— and render them wherever the grid needs them. - Give each slot its own
loading.tsx. A Suspense boundary per slot means the KPI strip paints while the chart is still fetching, instead of the whole page sitting blank. - Add a
default.tsxto any slot that has no matching sub-route, so soft navigation into unrelated parts of the dashboard doesn't 404 the slot. - Keep slot-specific data fetching inside the slot's own
page.tsx. Don't fetch everything in the shared layout and prop-drill it down; that reintroduces the single-slow-fetch problem you just split apart.
You've shipped that dashboard before the split existed. Did the whole page really need to wait on the slowest query just because one panel happened to be on the same route? Parallel routes exist specifically so that stops being the default.
Where This Still Needs Route Handlers
Not everything moves to a Server Action just because it's mutating data. Stripe webhooks, Resend inbound email, anything a background worker calls on a schedule, and any endpoint a mobile client hits directly all stay on Route Handlers, because none of those callers can invoke a framework-internal Server Action in the first place. What a Next.js app router actually is hasn't changed underneath this: file-based routing, server rendering, and an API layer, with Server Actions as a newer, narrower tool layered on top rather than a replacement for the whole API surface.
The billing state machine I wired for BookBed's webhook-driven flow is a good example of the shape, even though that project runs on Firebase Functions rather than Next.js: the webhook handler is the one place allowed to write the source-of-truth status, and every other code path reads from it instead of re-deriving state. Swap the runtime and the rule holds. A Route Handler that receives a Stripe event needs to be exactly that same kind of single writer, whether it's fronted by Next.js or Cloud Functions.
Signature verification belongs in the Route Handler too, not somewhere downstream. Verify the webhook signature before you touch the payload, respond fast so the provider doesn't retry into a duplicate, and make the write idempotent against the event ID so a legitimate retry doesn't double-charge a state transition. None of that logic has any business being a Server Action; there's no human in the loop to trigger it, and Server Actions assume one.
Picking the Rest of the Stack Around This
App Router patterns don't exist in isolation from the rest of your data layer. If you're on Postgres, a Prisma-and-Next.js setup keeps your schema and your route handlers type-safe against the same source of truth, and the singleton-client rule for Prisma is the same lesson as the singleton-client rule for Supabase: never instantiate a fresh client per request on the server, or you'll exhaust your connection pool by lunchtime. If you're on Supabase, pairing Supabase Auth with the App Router means middleware calling getUser(), never the unverified getSession(), for anything gating access.
None of this is exotic. It's mostly a handful of rules that are easy to state and easy to violate under deadline pressure, which is exactly why they're worth writing down instead of relearning at 11pm during an incident. If you're scoping a new SaaS build and haven't locked in the rest of the stack yet, the tech choices that actually hold up under production load are a reasonable place to start before you pick a caching strategy for a codebase that doesn't exist yet.
Go check one specific thing today: open your mutations folder and count how many Server Actions are missing an authorization check because the Zod schema felt like enough.
