Next.js 15 for SaaS: What Carried Into 16
Next.js 15 is no longer the newest major, and that is exactly why it is worth writing about. The releases that followed built on its decisions rather than reversing them, so the architecture a SaaS team chose in 2024 or 2025 is the architecture it still runs in 2026. This article is about those durable decisions: what 15 changed, what carried into 16, and how to shape a multi-tenant product on top of them so it stays fast as the customer list grows. We build and run our own SaaS products on this stack, including Passforge, an exam-prep platform with custom billing, and DibsHub, a booking product for independent providers, so the advice here comes from operating the thing, not from reading the changelog.
What Next.js 15 changed, and what survived
Version 15 was the release where the framework stopped guessing on your behalf. Its most consequential changes were about defaults, and defaults are what a codebase inherits for years.
| Change in 15 | Status after 16 | What it means for a SaaS codebase |
|---|---|---|
fetch and GET route handlers are no longer cached by default | Kept, and extended into an explicit caching model | Tenant data is dynamic unless you say otherwise; the old stale-data surprises are gone |
Request APIs (cookies(), headers(), params) became async | Kept; the synchronous forms were removed | Every auth and tenant check has to await; codemods handle the mechanical part |
| React 19 support, Server Actions matured | Kept; React 19 is the baseline | Form handling and mutations run on the server with typed inputs |
| Turbopack for development | Default bundler in 16 for dev and build | Faster feedback loops; webpack-specific config needs a look during upgrade |
Partial prerendering and use cache as experiments | Formalised in 16 under the Cache Components model | Static shell plus dynamic holes is now a first-class way to build logged-in pages |
middleware.ts at the edge | Renamed to proxy.ts in 16, same role | A rename with a codemod; your host routing and auth redirects live in the same place |
The pattern is clear: nothing 15 introduced was rolled back. If your team learned the server-first, explicit-caching model then, that knowledge is still current. What follows is how to apply it.
Server Components as the default shape of a SaaS page
In the App Router, a page is a React Server Component unless a file opts into the browser with "use client". For a logged-in product that default is a gift. Data is fetched next to the database, secrets never leave the server, and the browser receives rendered HTML plus only the JavaScript the interactive parts need. The mental model for a dashboard is: the layout authenticates and resolves the tenant once, server components below it read data through that context, and a thin layer of client components handles the parts a user actually touches.
// app/(app)/layout.tsx (server component)
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { resolveSession } from "@/lib/auth";
export default async function AppLayout({ children }) {
const session = await resolveSession(await cookies()); // async in 15+
if (!session) redirect("/login");
return <Shell user={session.user} tenant={session.tenant}>{children}</Shell>;
}
// app/(app)/invoices/page.tsx (server component, no client JS for the table)
import { withTenant } from "@/lib/tenant-db";
export default async function InvoicesPage() {
const session = await requireSession();
const invoices = await withTenant(session.tenantId, (db) => db.invoice.findMany());
return <InvoiceTable rows={invoices} />; // InvoiceTable is a client component
}Notice that the tenant check is a server concern in both files. The browser never decides which tenant it belongs to; it only renders what the server already filtered. Pair this with database-level isolation and the two layers cover each other, which is the subject of our companion piece on Postgres Row-Level Security.
Caching: say what you mean
Before 15, fetch results were cached unless you opted out, and many teams shipped stale tenant data before they understood why. Since 15 the default is inverted: nothing is cached until you ask. That is the safer default for a product where every request belongs to a customer, and it moves caching from an accident to a design decision. The rule of thumb we apply on every SaaS:
Cache the marketing surface hard, treat tenant data as dynamic by default, and cache specific expensive reads deliberately, tagged, so a mutation can invalidate exactly what it changed.
Next.js 16 formalised the deliberate half of that rule with the use cache directive and the Cache Components model. A function or component marked use cache is cached with a lifetime and tags you control; the rest of the tree stays dynamic. The important design choice for multi-tenant products is that the cache key must include the tenant, and the cleanest way to guarantee that is to pass the tenant id as an argument so it becomes part of the key automatically:
// lib/reports.ts
import { cacheTag, cacheLife } from "next/cache";
export async function monthlySummary(tenantId: string, month: string) {
"use cache";
cacheLife("hours"); // a preset lifetime; tune per read
cacheTag(`summary:${tenantId}`); // tenant-scoped tag for invalidation
return withTenant(tenantId, (db) => db.$queryRaw`...expensive aggregate...`);
}
// After a mutation that changes the numbers:
import { revalidateTag } from "next/cache";
revalidateTag(`summary:${tenantId}`);Never cache a function that reads the tenant from ambient context without also including it in the arguments; that is how one customer's report ends up on another customer's screen. Check the exact API names against the release notes for the version you are on, since this area evolved between 15 and 16, but the principle holds across both: explicit lifetime, explicit tags, tenant in the key.
Streaming and Suspense: the part users feel
Data-dense products used to choose between a slow full-page render and a page full of spinners. Streaming removes that choice. The server sends the shell immediately and streams each slow region as its data resolves, with <Suspense> marking the boundaries. Navigation, sidebar and page header appear at once; the heavy chart fills in a beat later, and the page never blocks on its slowest query.
// app/(app)/dashboard/page.tsx
export default function Dashboard() {
return (
<>
<PageHeader title="Overview" /> {/* instant */}
<Suspense fallback={<KpiSkeleton />}>
<KpiRow /> {/* fast query */}
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart /> {/* slow aggregate */}
</Suspense>
</>
);
}Two practical notes. Place boundaries around the regions with genuinely different data costs, not around every component; too many boundaries produce a page that visibly pops into place in six stages. And give each fallback the same dimensions as the real content so the layout does not shift when the data lands, which is both a user-experience detail and a Core Web Vitals one.
Server Actions for your own UI, route handlers for everyone else
Mutations that used to require an API route, a fetch wrapper and a hand-written validation layer can be a Server Action: a function marked "use server" that a form or a client component calls directly. Auth, validation and the database write live in one place on the server.
// app/(app)/settings/actions.ts
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
const Input = z.object({ name: z.string().min(2).max(80) });
export async function renameWorkspace(formData: FormData) {
const session = await requireSession(); // never trust the caller
const { name } = Input.parse(Object.fromEntries(formData));
await withTenant(session.tenantId, (db) =>
db.tenant.update({ where: { id: session.tenantId }, data: { name } })
);
revalidatePath("/settings");
}Treat every action as a public endpoint, because it is one: anyone with the action id can call it with any payload. Authenticate inside the action, validate the input with a schema, and authorise against the tenant, every time. Keep conventional route handlers for the things actions are not for: webhooks from payment providers, a mobile app, third-party integrations and anything that needs a stable, documented HTTP contract.
Where auth and tenancy live: proxy, layout, and data layer
A SaaS has three natural places to enforce access, and a healthy codebase uses all three for different jobs. The proxy (formerly middleware) runs at the edge before rendering: it is the right place for coarse decisions like “no session cookie, redirect to login”, host-based routing for a separate admin domain, and locale handling. It should not touch the database. The layout resolves the session and tenant once per request and makes them available to the tree; anything that needs the user's identity reads it here. The data layer enforces tenant scoping on every read and write, ideally with the database itself as a backstop. When a team puts everything in the proxy, they end up with database calls at the edge and a slow, fragile gate; when they put everything in components, they end up with a hundred scattered checks. Three layers, each doing its own job, is the durable shape.
Static shell, dynamic holes: partial prerendering for logged-in apps
The most interesting idea to come out of 15 and mature in 16 is that a page can be mostly static and partly dynamic at the same time. The chrome of a dashboard, its navigation, headers and empty states, is identical for every tenant and can be prerendered once at build time and served from a CDN. The data inside it is dynamic and streams in per request. Cache Components is the mechanism: mark the static parts cacheable, let the dynamic parts read request data, and the framework composes the two at the edge. For a SaaS this means the first paint of a logged-in page is as fast as a marketing page, while the numbers remain per-customer and fresh. It requires discipline about which components read request-scoped data, which is the discipline this whole article has been arguing for anyway.
Deployment: the same architecture, your infrastructure
Everything above works on Vercel and it works self-hosted. The framework's output: "standalone" mode produces a self-contained server you can run under PM2 or a container behind Nginx, and that is how we ship our own products; the step-by-step is in our guide to deploying Next.js on a plain Ubuntu box. The tradeoffs to know: on your own server, caching is in-process or in a store you configure, so a multi-instance deployment needs a shared cache handler; image optimisation runs on your CPU; and features that assume a serverless or edge runtime need a check against your actual runtime. None of that is a reason to avoid self-hosting for a SaaS with predictable traffic, and the cost difference at scale is real. It is a reason to decide early and test the caching layer in the environment where it will run.
Pages Router: do not rewrite for its own sake
The Pages Router still ships, still receives maintenance, and still runs a great deal of production software, including parts of our own. If you own a Pages Router SaaS that is healthy, the right move is usually incremental: adopt the App Router for new surfaces, share the data layer and the auth library, and migrate old routes when you touch them for other reasons. A wholesale rewrite to chase the newer router spends months on work customers cannot see. The exception is a product whose main pain is exactly what the App Router fixes, heavy client bundles and waterfall data fetching on dense pages; there, a planned migration of the worst pages first pays back quickly.
Upgrade checklist for an existing product
- Run the official codemods first; they handle async request APIs and the proxy rename.
- Audit every
fetchand route handler that silently depended on the old caching default, and add explicit caching only where you want it. - Grep for anything that reads the tenant from ambient context inside a cached function; pass it as an argument instead.
- Move database calls out of the proxy if any crept in.
- Add a schema-validated auth check to every Server Action, then test one with a forged payload.
- If you self-host with more than one instance, configure a shared cache handler before turning on
use cache. - Build with Turbopack and fix anything that depended on webpack-specific loaders.
- Ship the upgrade as its own release, not inside a feature sprint, so a regression has one obvious cause.
A decision framework
- New SaaS, greenfield: App Router, server-first, explicit caching, Server Actions for your UI, route handlers for integrations. No debate.
- Healthy Pages Router product: stay, adopt the App Router incrementally, share the data and auth layers.
- Dense dashboards that feel slow: streaming with a few well-placed boundaries first; it is the cheapest big win.
- Enterprise customers asking about data isolation: pair the server-side tenant context with database-level RLS and be able to show both.
- Real-time features on the roadmap: keep them on a dedicated channel rather than forcing them through the rendering model; see our piece on WebSockets and live data.
- Passwordless sign-in as a differentiator: it fits naturally in the layout-level session model; see passkeys.
Modern Next.js rewards teams that commit to the server-first model and punishes teams that fight it with old habits. The version number matters less every year; the architecture matters more. If you want a partner that has shipped and operates SaaS on this stack, see how we approach SaaS development and web applications, or tell us what you are building.
Frequently asked questions
Is Next.js 15 still relevant now that Next.js 16 is out?
Yes, because 16 built on 15's decisions rather than reversing them. The server-first App Router, the inverted caching default where nothing is cached until you ask, async request APIs, React 19 and Server Actions all carried forward. 16 formalised the experimental pieces, notably the use cache directive under the Cache Components model, made Turbopack the default bundler and renamed middleware to proxy. A team that learned the 15 model is current; the upgrade is mostly codemods plus a caching audit.
Should a SaaS use the App Router or the Pages Router?
A new SaaS should start on the App Router: server components by default, streaming with Suspense, Server Actions for its own UI and route handlers for integrations. A healthy existing Pages Router product should not be rewritten wholesale; adopt the App Router for new surfaces, share the data and auth layers, and migrate old routes when you touch them for other reasons. The exception is a product whose main pain is heavy client bundles and waterfall fetching on dense pages, where migrating the worst pages first pays back quickly.
How does caching work in Next.js 15 and 16 for multi-tenant data?
Since 15, fetch and GET route handlers are not cached unless you opt in, so tenant data is dynamic by default. When you do cache an expensive read with use cache, give it an explicit lifetime and tag, and pass the tenant id as an argument so it becomes part of the cache key; never let a cached function read the tenant from ambient context. After a mutation, revalidate the tenant-scoped tag. On a self-hosted deployment with more than one instance, configure a shared cache handler before enabling it.
Are Server Actions safe enough for a SaaS?
They are, provided you treat every action as a public endpoint, because that is what it is. Authenticate inside the action, validate the input with a schema, and authorise the operation against the caller's tenant on every call. Keep conventional route handlers for webhooks, mobile clients and third-party integrations that need a stable, documented HTTP contract. Actions remove a lot of glue code for your own UI without changing the security model.
Where should authentication and tenant checks live in a Next.js app?
In three layers, each doing one job. The proxy (formerly middleware) makes coarse edge decisions such as redirecting a request with no session cookie, host routing and locale handling, and should never touch the database. The root layout resolves the session and tenant once per request for the whole tree. The data layer scopes every read and write to that tenant, ideally with Postgres Row-Level Security as a backstop so a forgotten filter cannot leak data.
Can I self-host a Next.js SaaS instead of using Vercel?
Yes. The standalone output mode produces a self-contained server you can run under PM2 or in a container behind Nginx, and the whole server-first architecture works there. Know the tradeoffs: caching lives in-process or in a store you configure, image optimisation runs on your CPU, and any feature that assumes a serverless or edge runtime needs checking against your actual environment. For predictable SaaS traffic the cost difference at scale is real, so decide early and test the caching layer where it will run.
Thinking about building this?
Appluex designs and ships production mobile & web apps. Including AI features. Let's talk.