Most B2B SaaS teams pick a Nuxt rendering mode once, at project setup, and then live with the consequences for years. Either everything is server-rendered, so the logged-in dashboard pays for a Node server it does not need, or everything is a single-page app, so the pricing and docs pages that should rank in search ship an empty <div id="__nuxt">. Nuxt 3 does not force that choice. With route rules you can decide per route, and for a typical B2B product the right answer is almost always hybrid.
At Loop Origin we build customer portals, dealer ordering systems and SaaS dashboards on Nuxt 3 with Laravel or Node backends. This is the decision framework I use, the exact nuxt.config.ts we start from, and the failure modes that have cost real debugging time.
Should a B2B SaaS app use SSR, SPA or hybrid rendering in Nuxt 3?
Direct answer: Use hybrid rendering. Prerender or cache public marketing, pricing and docs pages for SEO and fast first paint, and serve the authenticated app as a client-rendered SPA with ssr: false route rules. Reserve full server rendering for public pages whose content changes per request, and never cache any server-rendered HTML that depends on the user's session cookie.
The four rendering modes, compared
| Mode | How HTML is produced | SEO | First load (TTFB) | Server needed | Best fit in B2B SaaS |
|---|---|---|---|---|---|
| SSR (universal) | Rendered by Nitro on every request | Strong | Depends on API latency per request | Yes, Node or edge runtime | Public pages with per-request data |
SPA (ssr: false) |
Empty shell, rendered in the browser | Weak for content pages | Fast shell, slower meaningful paint | No, static hosting is enough | Logged-in dashboards, admin panels |
| Prerender (SSG) | Rendered once at build time | Strong | Very fast, served from CDN | No | Marketing, pricing, docs, changelog |
| SWR / ISR cache | Rendered on demand, cached, revalidated | Strong | Fast after the first hit | Yes, or a platform with ISR support | Large catalogs, public listings, help centers |
The column people skip is "Server needed". A pure SPA plus prerendered marketing pages can be served from any static host or CDN. One runtime SSR route means running a Node process or edge function.
Map routes to modes, not the whole app
For a typical B2B product, I split routes into four groups:
-
Marketing and pricing (
/,/pricing,/features/**): content changes on deploy. Prerender. -
Docs, blog, changelog (
/docs/**,/blog/**): many pages, edited through a CMS. Prerender, or SWR if you publish without redeploying. -
Public dynamic pages (
/integrations/**, public status or listing pages): data from an API, same for every visitor. SWR with a sensible revalidation window. -
Authenticated app (
/app/**,/account/**,/admin/**): per-user data, never indexed. SPA.
Here is the configuration we start from:
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/': { prerender: true },
'/pricing': { prerender: true },
'/features/**': { prerender: true },
'/docs/**': { swr: 3600 }, // cache 1h, revalidate in background
'/integrations/**': { swr: 600 },
'/app/**': { ssr: false }, // client-rendered dashboard
'/account/**': { ssr: false },
'/admin/**': { ssr: false, headers: { 'X-Robots-Tag': 'noindex' } },
'/api/**': { headers: { 'Cache-Control': 'no-store' } },
'/old-pricing': { redirect: { to: '/pricing', statusCode: 301 } },
},
nitro: {
prerender: { crawlLinks: true, routes: ['/sitemap.xml'] },
},
})
Two production notes. First, swr caching is handled by Nitro's cache layer, which defaults to in-memory storage; on a multi-instance deployment each instance keeps its own cache unless you configure a shared storage driver such as Redis. Second, isr route rules are only meaningful on platforms that implement them (Vercel and Netlify, for example). On a plain VPS, swr is the equivalent you actually get.
SEO and performance: what actually changes
Search engines can render JavaScript, but in my experience pages that ship complete HTML index more reliably and preview correctly on LinkedIn, Slack or X, whose link-preview crawlers generally do not run JavaScript.
For TTFB, the trade-offs are structural, not magic:
- Prerendered pages are static files; TTFB is whatever your CDN delivers.
-
SSR TTFB includes every
useFetchoruseAsyncDatacall that runs on the server before HTML is sent. One slow backend endpoint makes every page slow. Parallelize and time-box fetches. - SPA routes return a tiny shell fast, then the browser downloads JavaScript and calls your API. Perceived speed depends on bundle size and API latency, acceptable behind a login.
Measure your own routes:
curl -s -o /dev/null -w 'ttfb=%{time_starttransfer}s total=%{time_total}s\n' https://app.example.test/pricing
curl -s -o /dev/null -w 'ttfb=%{time_starttransfer}s total=%{time_total}s\n' https://app.example.test/docs/getting-started
Run each twice; with swr, the second request should come from cache.
Hosting cost drivers by mode
Exact prices change with provider and traffic, but the cost structure is predictable:
| Setup | What you pay for | Cost behaviour as traffic grows | Ops burden |
|---|---|---|---|
| SPA + prerendered pages on a static host/CDN | Bandwidth, build minutes | Grows slowly; static files are cheap to serve | Lowest |
| Hybrid on one VPS (Nitro node server behind Nginx) | Fixed monthly server | Flat until CPU is saturated by SSR | Medium: process manager, updates, monitoring |
| Hybrid on serverless or edge | Invocations and compute time | Scales with SSR requests; cached routes reduce it | Low to medium |
| Full SSR on every route | Compute for every page view, including the dashboard | Grows fastest; you pay to render pages nobody indexes | Medium to high |
The expensive mistake is full SSR for the authenticated app: you pay server compute to render dashboards for logged-in users, gain nothing in SEO, and add a second place where auth can go wrong. My VPS vs managed PaaS TCO breakdown covers the hosting decision itself, and multi-stage Dockerfiles for PHP and Node shows how we package the Nitro server.
Failure modes I have debugged in production
1. Auth cookies leaking into cached SSR
This is the dangerous one. A page under an swr rule reads the session cookie during SSR (for example, a header that shows "Signed in as ..."), and Nitro caches the rendered HTML keyed by URL. The next anonymous visitor receives the first user's name, or worse, their data.
Rules we enforce:
- Any route that reads cookies,
useRequestHeaders(['cookie'])or session state on the server must not haveswr,isrorprerender. - Personalized fragments on public pages render client-side only, inside
<ClientOnly>. - Cached responses must never carry
Set-Cookie. Check withcurl -sIagainst a cached route.
2. Hydration mismatches
Vue warns "Hydration completed but contains mismatches" when server HTML differs from the first client render. The usual causes in B2B apps are dates and time zones (server in UTC, browser in the user's zone), Math.random() or Date.now() in templates, and reading localStorage or window during setup. Fixes: format dates on the client or pass the user's time zone explicitly, generate IDs with useId(), and move browser-only logic into onMounted or <ClientOnly>.
3. Forwarding cookies to the API during SSR
On the server, $fetch to your Laravel or Node API does not automatically carry the browser's cookies. If an SSR page needs authenticated data, use useRequestFetch() or pass headers: useRequestHeaders(['cookie']) explicitly, and keep that route out of every cache rule.
4. Middleware that assumes the browser
Route middleware runs on the server for SSR routes and on the client for SPA navigation. Code that reads localStorage for a token breaks on the server. With ssr: false on /app/**, that middleware runs only in the browser.
Rendering decision checklist
- List every route group and mark it public or authenticated.
- Authenticated groups:
ssr: false, plusnoindexfor admin areas. - Public pages that change only on deploy:
prerender: true. - Public pages with API data shared by all visitors:
swrwith a revalidation window you can defend. - Confirm no cached route reads cookies or session state on the server.
- Configure shared Nitro cache storage if you run more than one instance.
- Measure TTFB per route group with
curl -wbefore and after. - Check cached responses for stray
Set-Cookieheaders. - Test the hydration console warnings on the top five public pages.
- Document the rules in the repo so new routes get a deliberate mode.
If you are planning a portal or SaaS front end, this split is the default in our custom web application development work; the pricing page shows how we scope it.
Frequently asked questions
Is SSR required for SEO in Nuxt 3?
Not for every route. Public content pages should ship full HTML, which prerendering or SWR caching provides without per-request rendering. Authenticated dashboards are not indexed, so client-side rendering is fine there and avoids server cost.
What is the difference between swr and isr route rules?
Both serve cached HTML and revalidate it. swr is implemented by Nitro's own cache and works on a Node server or VPS. isr relies on the hosting platform's incremental static regeneration support, such as Vercel or Netlify, and has no effect where the platform does not implement it.
Can I mix SSR and SPA routes in one Nuxt 3 app?
Yes. Route rules let you set ssr: false on specific path patterns while the rest of the app stays universal or prerendered. It is still one build.
Why does my Nuxt page show another user's data?
Most often because a server-rendered page that reads the session cookie is under a cache rule such as swr, isr or a CDN cache. The first rendered HTML is cached and served to everyone. Remove caching from that route or render personalized parts on the client only.
Do I need a Node server to deploy Nuxt 3?
Only if some routes render at request time. An app made entirely of prerendered pages and ssr: false routes can be generated as static files and served from a CDN. Any SSR or SWR route requires the Nitro server, a serverless function or an edge runtime.
How do I fix hydration mismatch warnings?
Make the first client render match the server HTML. Avoid random values and current time in templates, format dates consistently or on the client, use useId() for generated IDs, and move access to window or localStorage into onMounted or a <ClientOnly> wrapper.
Follow & Connect
- Website: Loop Origin
- LinkedIn: Loop Origin
- X: Muhammad Ali Mughal (@AliMughal887)
- GitHub: Loop Origin (@LoopOrigin)

Top comments (0)